diff --git a/qa/scenarios/ui/tui-terminal-safety-pty.yaml b/qa/scenarios/ui/tui-terminal-safety-pty.yaml new file mode 100644 index 000000000000..25b499d264cf --- /dev/null +++ b/qa/scenarios/ui/tui-terminal-safety-pty.yaml @@ -0,0 +1,60 @@ +title: TUI terminal rendering and output safety PTY contracts +scenario: + id: tui-terminal-safety-pty + surface: tui + category: tui.rendering-and-output-safety + coverage: + primary: + - tui.terminal-rendering-primitives + - tui.output-safety + risk: medium + objective: Prove narrow terminal rendering and hostile display-boundary sanitization through the real runTui PTY loop. + successCriteria: + - Long Unicode output renders through narrow real PTY frames without replacement characters. + - Wrapped URLs retain their complete copy-safe OSC 8 target. + - ANSI, OSC, clipboard, and C1 payloads cannot reach raw terminal frames. + - Printable text surrounding hostile terminal controls remains visible. + - A row-aware synchronized-frame oracle proves selector labels, descriptions, footer fields, connection status, BTW questions, and complete headers collapse CR, LF, and tab payloads to exact single-space one-line copies while preserving printable Unicode and RTL isolation. + - The authenticated row oracle uses the final bounded viewport at the fixture's exact PTY dimensions, including autowrap and bottom scrolling, and matches only visible cells authenticated by balanced synchronized frames; later frames preserve untouched authenticated cells, while unsynchronized writes and erases revoke touched cells. + - The oracle has negative controls for split rows, literal tabs, stale spaces, post-frame text and erases, right-margin wrapping, scrolling, and incomplete synchronized frames or trailing controls, which invalidate prior frames until controls and OSC 8 state are complete. + - Hostile selected session and agent identity fields cannot inject controls through the header while their stored identity remains raw. + - Live tool titles sanitize before trusted styling, tool-result Markdown sanitizes source before parsing and isolates rendered RTL content outside OSC 8 and padding, and control-only system or BTW error output renders a neutral visible fallback. + - Restored in-flight Markdown and dynamic command descriptions sanitize display copies, while unsafe thinking-level completion values are omitted before editor rendering. + - Model and session selections pass exact unmodified backend values to patchSession and loadHistory. + codeRefs: + - src/tui/components/btw-inline-message.ts + - src/tui/components/chat-log.ts + - src/tui/components/filterable-select-list.ts + - src/tui/components/hyperlink-markdown.ts + - src/tui/components/searchable-select-list.ts + - src/tui/components/tool-execution.ts + - src/tui/tui-autocomplete.ts + - src/tui/tui-pty-harness-assertion-test-support.ts + - src/tui/tui-pty-harness.e2e.test.ts + - src/tui/tui.ts + - src/tui/tui-formatters.ts + - src/tui/osc8-hyperlinks.ts + execution: + kind: script + path: test/e2e/qa-lab/tui/tui-pty-evidence-producer.ts + summary: Runs independently matched real-PTY rendering and output-safety assertions and authenticates their fresh Vitest results. + timeoutMs: 180000 + args: + - --artifact-base + - ${outputDir} + - --scenario-id + - ${scenarioId} + config: + tuiPtyCases: + - coverageId: tui.terminal-rendering-primitives + testFile: src/tui/tui-pty-harness.e2e.test.ts + testNamePattern: ^TUI PTY harness renders long Unicode output and copy-safe URLs in narrow real PTY frames$ + - coverageId: tui.output-safety + testFile: src/tui/tui-pty-harness.e2e.test.ts + testNamePattern: ^TUI PTY harness sanitizes ANSI OSC and C1 payloads across real PTY display boundaries$ + - coverageId: tui.output-safety + testFile: src/tui/tui-pty-harness-assertion-test-support.test.ts + testNamePattern: ^hasSynchronizedFrameRow requires exact single-space text and all markers on one completed row$ + - coverageId: tui.output-safety + testFile: src/tui/tui-pty-harness-assertion-test-support.test.ts + testNamePattern: ^hasSynchronizedFrameRow rejects terminal row reconstruction false positives$ diff --git a/src/tui/components/btw-inline-message.test.ts b/src/tui/components/btw-inline-message.test.ts index 52f031e990cc..1b4d20bac48e 100644 --- a/src/tui/components/btw-inline-message.test.ts +++ b/src/tui/components/btw-inline-message.test.ts @@ -1,5 +1,6 @@ // BTW inline message tests cover compact inline status message rendering. import { describe, expect, it } from "vitest"; +import { normalizeTestText } from "../../../test/helpers/normalize-text.js"; import { BtwInlineMessage } from "./btw-inline-message.js"; describe("btw inline message", () => { @@ -17,4 +18,96 @@ describe("btw inline message", () => { " Press Enter or Esc to dismiss ", ]); }); + + it("sanitizes the question and successful Markdown result on every update", () => { + const firstQuestionAttack = "\x1b[3Jquestion"; + const firstTextAttack = "\x1b]52;c;T08_BTW_CLIPBOARD\x07"; + const nextQuestionAttack = "\u009b3Jquestion"; + const nextTextAttack = "\u009d0;T08_BTW_TITLE\u009c"; + const url = "https://example.test/tui/btw?copy=caf%C3%A9"; + const message = new BtwInlineMessage({ + question: `first ${firstQuestionAttack}\r\nمرحبا\tשלום`, + text: `${firstTextAttack}**first café**\nsecond body ${url}`, + }); + + let lines = message.render(140); + let raw = lines.join("\n"); + let rendered = normalizeTestText(raw); + expect(rendered).toContain("BTW: \u2067first question مرحبا שלום\u2069"); + expect(rendered).toContain("مرحبا שלום"); + expect(rendered).toContain("first café"); + expect(rendered).toContain("second body"); + expect(lines[1]).not.toMatch(/[\r\n\t]/u); + expect(raw).toContain(`\x1b]8;;${url}\x07`); + expect(raw).not.toContain(firstQuestionAttack); + expect(raw).not.toContain(firstTextAttack); + + message.setResult({ + question: `next ${nextQuestionAttack}\r\nשלום\tمرحبا`, + text: `${nextTextAttack}**next 東京** ${url}`, + }); + + lines = message.render(140); + raw = lines.join("\n"); + rendered = normalizeTestText(raw); + expect(rendered).toContain("BTW: \u2067next question שלום مرحبا\u2069"); + expect(rendered).toContain("שלום مرحبا"); + expect(rendered).toContain("next 東京"); + expect(rendered).not.toContain("first question"); + expect(rendered).not.toContain("first café"); + expect(raw).not.toContain(nextQuestionAttack); + expect(raw).not.toContain(nextTextAttack); + expect(lines[1]).not.toMatch(/[\r\n\t]/u); + }); + + it("passes sanitized RTL source through Markdown exactly once", () => { + const message = new BtwInlineMessage({ + question: "structure proof", + text: "\u202e# مرحبا\u202c\n\n\u200f> שלום\n\n\u2066- عنصر\u2069", + }); + + const lines = message.render(100); + const normalized = lines + .map((line) => normalizeTestText(line).replace(/[\u2067\u2069]/gu, "")) + .join("\n"); + expect(normalized).toContain("مرحبا"); + expect(normalized).not.toContain("# مرحبا"); + expect(normalized).toContain("│ שלום"); + expect(normalized).toContain("- عنصر"); + for (const line of lines.filter((entry) => /[\u0590-\u08ff]/u.test(entry))) { + expect(line.match(/\u2067/gu)).toHaveLength(1); + expect(line.match(/\u2069/gu)).toHaveLength(1); + } + }); + + it("sanitizes BTW error questions and text before applying trusted themes", () => { + const questionAttack = "\x1b]0;T08_BTW_QUESTION_TITLE\x07"; + const errorAttack = "\u009b3Jretry"; + const message = new BtwInlineMessage({ + question: `why ${questionAttack}failed?\r\nمرحبا\tשלום`, + text: `${errorAttack} **plain** safely café`, + isError: true, + }); + + const raw = message.render(100).join("\n"); + const rendered = normalizeTestText(raw); + expect(rendered).toContain("BTW: \u2067why failed? مرحبا שלום\u2069"); + expect(rendered).toContain("مرحبا שלום"); + expect(rendered).toContain("retry **plain** safely café"); + expect(raw).not.toContain(questionAttack); + expect(raw).not.toContain(errorAttack); + }); + + it.each(["", " \x1b]0;hidden title\x07 "])( + "renders a visible fallback when a BTW error sanitizes to empty", + (text) => { + const message = new BtwInlineMessage({ + question: "what failed?", + text, + isError: true, + }); + + expect(normalizeTestText(message.render(80).join("\n"))).toContain("(no output)"); + }, + ); }); diff --git a/src/tui/components/btw-inline-message.ts b/src/tui/components/btw-inline-message.ts index 2ed18085ae15..f6c20a5c001e 100644 --- a/src/tui/components/btw-inline-message.ts +++ b/src/tui/components/btw-inline-message.ts @@ -1,6 +1,7 @@ // BTW inline message component renders compact aside messages in chat. import { Container, Spacer, Text } from "@earendil-works/pi-tui"; import { theme } from "../theme/theme.js"; +import { sanitizeRenderableLine, sanitizeRenderableText } from "../tui-formatters.js"; import { AssistantMessageComponent } from "./assistant-message.js"; // Inline overlay message for BTW follow-up answers inside the chat log. @@ -19,13 +20,21 @@ export class BtwInlineMessage extends Container { /** Replaces the current BTW content without reallocating the host component. */ setResult(params: BtwInlineMessageParams) { + const question = sanitizeRenderableLine(params.question); + let text = params.text; + if (params.isError) { + text = sanitizeRenderableText(text); + if (!text.trim()) { + text = "(no output)"; + } + } this.clear(); this.addChild(new Spacer(1)); - this.addChild(new Text(theme.header(`BTW: ${params.question}`), 1, 0)); + this.addChild(new Text(theme.header(`BTW: ${question}`), 1, 0)); if (params.isError) { - this.addChild(new Text(theme.error(params.text), 1, 0)); + this.addChild(new Text(theme.error(text), 1, 0)); } else { - this.addChild(new AssistantMessageComponent(params.text)); + this.addChild(new AssistantMessageComponent(text)); } this.addChild(new Text(theme.dim("Press Enter or Esc to dismiss"), 1, 0)); } diff --git a/src/tui/components/chat-log.test.ts b/src/tui/components/chat-log.test.ts index 7ebf67afc234..65540777c3eb 100644 --- a/src/tui/components/chat-log.test.ts +++ b/src/tui/components/chat-log.test.ts @@ -16,16 +16,49 @@ describe("ChatLog", () => { expect(rendered).not.toContain("system-1"); }); + it("sanitizes terminal controls before rendering an initial system message", () => { + const chatLog = new ChatLog(20); + const sgr = "\x1b[38;5;201mcolor"; + const erase = "\x1b[3Jerase"; + const osc52 = "\x1b]52;c;T08_CHAT_LOG_CLIPBOARD\x07"; + const c1Csi = "\u009b3Jc1"; + const c1Osc = "\u009d0;T08_CHAT_LOG_TITLE\u009c"; + + chatLog.addSystem(`before ${sgr}\x1b[0m ${erase} ${osc52}${c1Csi} ${c1Osc}after`); + + const raw = chatLog.render(120).join("\n"); + const rendered = normalizeTestText(raw); + expect(rendered).toContain("before color erase c1 after"); + for (const attack of [sgr, erase, osc52, c1Csi, c1Osc]) { + expect(raw).not.toContain(attack); + } + }); + it("coalesces consecutive repeatable system messages", () => { const chatLog = new ChatLog(20); + const rawText = "\x1b[?7777h\x1b]52;c;T08_CHAT_LOG_ONLY_CONTROLS\x07"; - chatLog.addSystem("no active run", { coalesceConsecutive: true }); - chatLog.addSystem("no active run", { coalesceConsecutive: true }); - chatLog.addSystem("no active run", { coalesceConsecutive: true }); + chatLog.addSystem(rawText, { coalesceConsecutive: true }); + chatLog.addSystem(rawText, { coalesceConsecutive: true }); + chatLog.addSystem(rawText, { coalesceConsecutive: true }); + + const raw = chatLog.render(120).join("\n"); + const rendered = normalizeTestText(raw); + expect(chatLog.children.length).toBe(1); + expect(rendered).toContain("(no output) x3"); + expect(raw).not.toContain(rawText); + }); + + it("does not coalesce distinct raw system messages that sanitize identically", () => { + const chatLog = new ChatLog(20); + + chatLog.addSystem("\x1b[?7776h", { coalesceConsecutive: true }); + chatLog.addSystem("\u009b777;888H", { coalesceConsecutive: true }); const rendered = normalizeTestText(chatLog.render(120).join("\n")); - expect(chatLog.children.length).toBe(1); - expect(rendered).toContain("no active run x3"); + expect(chatLog.children.length).toBe(2); + expect(rendered.match(/\(no output\)/g)).toHaveLength(2); + expect(rendered).not.toContain("x2"); }); it("does not coalesce ordinary system messages", () => { @@ -848,13 +881,18 @@ describe("ChatLog", () => { it("replaces an existing pending system notice for the same runId", () => { const chatLog = new ChatLog(40); + const firstAttack = "\x1b]52;c;T08_PENDING_FIRST\x07"; + const secondAttack = "\u009d0;T08_PENDING_SECOND\u009c"; - chatLog.addPendingSystem("run-1", "first notice"); - chatLog.addPendingSystem("run-1", "second notice"); + chatLog.addPendingSystem("run-1", `first ${firstAttack}notice`); + chatLog.addPendingSystem("run-1", secondAttack); - const rendered = chatLog.render(120).join("\n"); + const raw = chatLog.render(120).join("\n"); + const rendered = normalizeTestText(raw); expect(rendered).not.toContain("first notice"); - expect(rendered).toContain("second notice"); + expect(rendered).toContain("(no output)"); + expect(raw).not.toContain(firstAttack); + expect(raw).not.toContain(secondAttack); expect(chatLog.children.length).toBe(1); }); }); diff --git a/src/tui/components/chat-log.ts b/src/tui/components/chat-log.ts index 245950d75936..ca1aab42d81a 100644 --- a/src/tui/components/chat-log.ts +++ b/src/tui/components/chat-log.ts @@ -2,6 +2,7 @@ import type { Component } from "@earendil-works/pi-tui"; import { Container, Spacer, Text } from "@earendil-works/pi-tui"; import { theme } from "../theme/theme.js"; +import { sanitizeRenderableText } from "../tui-formatters.js"; import { AssistantMessageComponent } from "./assistant-message.js"; import { BtwInlineMessage } from "./btw-inline-message.js"; import { ToolExecutionComponent } from "./tool-execution.js"; @@ -194,13 +195,15 @@ export class ChatLog extends Container { this.pendingUsers.clear(); } - private formatRepeatedSystemText(text: string, count: number) { - return count > 1 ? `${text} x${count}` : text; + private formatSystemText(text: string, count = 1) { + const sanitized = sanitizeRenderableText(text); + const visible = sanitized.trim() || (text ? "(no output)" : ""); + return theme.system(count > 1 ? `${visible} x${count}` : visible); } private createSystemMessage(text: string): RepeatableSystemMessage { const entry = new Container(); - const textNode = new Text(theme.system(text), 1, 0); + const textNode = new Text(this.formatSystemText(text), 1, 0); entry.addChild(new Spacer(1)); entry.addChild(textNode); return { @@ -219,7 +222,7 @@ export class ChatLog extends Container { ) { this.repeatableSystemMessage.count += 1; this.repeatableSystemMessage.textNode.setText( - theme.system(this.formatRepeatedSystemText(text, this.repeatableSystemMessage.count)), + this.formatSystemText(text, this.repeatableSystemMessage.count), ); return; } diff --git a/src/tui/components/filterable-select-list.test.ts b/src/tui/components/filterable-select-list.test.ts index 378f0a3ddb68..90e9499c5e5d 100644 --- a/src/tui/components/filterable-select-list.test.ts +++ b/src/tui/components/filterable-select-list.test.ts @@ -166,4 +166,50 @@ describe("FilterableSelectList", () => { expect(list.getSelectedItem()?.value).toBe("codex"); }); + + it("sanitizes rendered fields without changing filtering or the selected value", () => { + const attacks = [ + "\u001b[38;5;201m", + "\u001b[3J", + "\u001b]0;filter-title\u0007", + "\u001b]52;c;filter-clipboard\u0007", + "\u009b2K", + "\u009d0;filter-c1-title\u009c", + ]; + const rawValue = `fv-start${attacks[1]}fv-end\r\nمرحبا\tשלום`; + const description = `fd-start${attacks[3]}fd-end\n東京`; + const list = new FilterableSelectList( + [ + { value: "decoy", label: "decoy" }, + { + value: rawValue, + label: attacks.join(""), + description, + searchText: "raw-filter-target", + }, + ], + 5, + mockTheme, + ); + let selectedValue: string | undefined; + list.onSelect = (item) => { + selectedValue = item.value; + }; + + typeInput(list, "raw-filter-target"); + const rendered = list.render(160).join("\n"); + + expect(rendered).toContain("fv-startfv-end"); + expect(rendered).toContain("مرحبا שלום"); + expect(rendered).toContain("fd-startfd-end 東京"); + expect(rendered).toContain("\u2067"); + expect(rendered).toContain("\u2069"); + for (const attack of attacks) { + expect(rendered).not.toContain(attack); + } + expect(rendered).not.toContain("fv-end\r\nمرحبا\tשלום"); + + list.handleInput("\r"); + expect(selectedValue).toBe(rawValue); + }); }); diff --git a/src/tui/components/filterable-select-list.ts b/src/tui/components/filterable-select-list.ts index 046378b47b68..eb011103ce8c 100644 --- a/src/tui/components/filterable-select-list.ts +++ b/src/tui/components/filterable-select-list.ts @@ -12,6 +12,7 @@ import { visibleWidth, } from "@earendil-works/pi-tui"; import chalk from "chalk"; +import { sanitizeRenderableLine } from "../tui-formatters.js"; export interface FilterableSelectItem extends SelectItem { /** Additional searchable fields beyond label */ @@ -42,7 +43,7 @@ export class FilterableSelectList implements Component, Focusable { this.maxVisible = maxVisible; this.theme = theme; this.input = new Input(); - this.selectList = new SelectList(this.allItems, maxVisible, theme); + this.selectList = this.createSelectList(this.allItems); } get focused(): boolean { @@ -55,13 +56,28 @@ export class FilterableSelectList implements Component, Focusable { private applyFilter(): void { if (!this.filterText.trim()) { - this.selectList = new SelectList(this.allItems, this.maxVisible, this.theme); + this.selectList = this.createSelectList(this.allItems); return; } const filtered = fuzzyFilter(this.allItems, this.filterText, (item) => [item.label, item.description, item.searchText].filter(Boolean).join(" "), ); - this.selectList = new SelectList(filtered, this.maxVisible, this.theme); + this.selectList = this.createSelectList(filtered); + } + + private createSelectList(items: FilterableSelectItem[]): SelectList { + return new SelectList( + items.map((item) => ({ + ...item, + label: + sanitizeRenderableLine(item.label || item.value) || + sanitizeRenderableLine(item.value) || + "(unnamed)", + description: sanitizeRenderableLine(item.description ?? ""), + })), + this.maxVisible, + this.theme, + ); } invalidate(): void { diff --git a/src/tui/components/hyperlink-markdown.test.ts b/src/tui/components/hyperlink-markdown.test.ts index 62c2c181bc33..7e819c0cb3c5 100644 --- a/src/tui/components/hyperlink-markdown.test.ts +++ b/src/tui/components/hyperlink-markdown.test.ts @@ -1,9 +1,21 @@ import { visibleWidth } from "@earendil-works/pi-tui"; import { describe, expect, it } from "vitest"; +import { splitAnsiSegments } from "../../../packages/terminal-core/src/ansi-sequences.js"; import { normalizeTestText } from "../../../test/helpers/normalize-text.js"; import { markdownTheme } from "../theme/theme.js"; import { HyperlinkMarkdown } from "./hyperlink-markdown.js"; +function osc8Targets(raw: string) { + return splitAnsiSegments(raw).flatMap((segment) => { + if (segment.kind !== "ansi" || !segment.value.startsWith("\x1b]8;;")) { + return []; + } + const terminatorLength = segment.value.endsWith("\x1b\\") ? 2 : 1; + const target = segment.value.slice("\x1b]8;;".length, -terminatorLength); + return target ? [target] : []; + }); +} + describe("HyperlinkMarkdown", () => { it("moves dunder identifiers intact across fenced code wrap boundaries", () => { const markdown = new HyperlinkMarkdown( @@ -41,6 +53,90 @@ describe("HyperlinkMarkdown", () => { const rendered = markdown.render(120).join("\n"); - expect(rendered).toContain(`\x1b]8;;${url}\x07${url}\x1b]8;;\x07`); + expect(rendered).toContain(`\x1b]8;;${url}`); + expect(normalizeTestText(rendered)).toContain("Wikipedia"); + }); + + it("sanitizes constructor and updated markdown before rendering", () => { + const attack = "\x1b]52;c;Y2xpcGJvYXJk\x07"; + const markdown = new HyperlinkMarkdown(`before${attack}after`, 0, 0, markdownTheme); + + expect(markdown.render(80).join("\n")).toContain("beforeafter"); + expect(markdown.render(80).join("\n")).not.toContain(attack); + + markdown.setText(`next\u009b31munsafe\u009b0m`); + const updated = markdown.render(80).join("\n"); + expect(updated).toContain("nextunsafe"); + expect(updated).not.toContain("\u009b"); + }); + + it("parses sanitized RTL headings quotes and lists identically on construct and update", () => { + const source = "\u202e# مرحبا\u202c\n\n\u200f> שלום\n\n\u2066- عنصر\u2069"; + const constructed = new HyperlinkMarkdown(source, 0, 0, markdownTheme); + const updated = new HyperlinkMarkdown("", 0, 0, markdownTheme); + + updated.setText(source); + const rendered = constructed.render(80); + expect(updated.render(80)).toEqual(rendered); + + const normalized = rendered + .map((line) => normalizeTestText(line).replace(/[\u2067\u2069]/gu, "")) + .join("\n"); + expect(normalized).toContain("مرحبا"); + expect(normalized).not.toContain("# مرحبا"); + expect(normalized).toContain("│ שלום"); + expect(normalized).toContain("- عنصر"); + expect(rendered.join("\n")).not.toMatch(/[\u200f\u202e\u2066]/u); + }); + + it("renders a neutral fallback when nonempty markdown sanitizes to empty", () => { + const markdown = new HyperlinkMarkdown("\x1b]0;title\x07", 0, 0, markdownTheme); + + expect(markdown.render(80).map(normalizeTestText).join("\n")).toContain("(no output)"); + + markdown.setText(""); + expect(markdown.render(80).map(normalizeTestText).join("\n")).not.toContain("(no output)"); + }); + + it("extracts copy-safe URLs from the sanitized display copy", () => { + const url = "https://example.test/path?mode=copy-safe#proof"; + const markdown = new HyperlinkMarkdown("", 0, 0, markdownTheme); + + markdown.setText(`visit ${url}\x1b]52;c;Y2xpcGJvYXJk\x07`); + const rendered = markdown.render(120).join("\n"); + + expect(rendered).toContain(`\x1b]8;;${url}`); + expect(normalizeTestText(rendered)).toContain(url); + expect(rendered).not.toContain("Y2xpcGJvYXJk"); + }); + + it("keeps an RTL bare URL target byte-exact", () => { + const url = "https://example.test/rtl-proof"; + const rendered = new HyperlinkMarkdown(`مرحبا ${url}`, 0, 0, markdownTheme).render(120)[0]; + + expect(rendered).toBeDefined(); + const targets = osc8Targets(rendered ?? ""); + expect(targets.length).toBeGreaterThan(0); + expect(targets.every((target) => target === url)).toBe(true); + expect(rendered?.startsWith("\u2067")).toBe(true); + expect(rendered?.trimEnd().endsWith("\u2069")).toBe(true); + expect(rendered?.indexOf("\u2067")).toBeLessThan(rendered?.indexOf("\x1b]8;;") ?? -1); + expect(rendered?.indexOf("\u2069")).toBeGreaterThan(rendered?.lastIndexOf("\x1b]8;;") ?? -1); + expect(visibleWidth(rendered ?? "")).toBe(120); + }); + + it("keeps horizontal padding outside isolates while OSC8 targets wrap intact", () => { + const url = "https://example.test/rtl-proof/with/a/long/path"; + const lines = new HyperlinkMarkdown(`مرحبا ${url}`, 2, 0, markdownTheme).render(24); + const rtlLine = lines.find((line) => line.includes("مرحبا")); + const targets = osc8Targets(lines.join("\n")); + + expect(lines.length).toBeGreaterThan(1); + expect(lines.every((line) => visibleWidth(line) === 24)).toBe(true); + expect(rtlLine?.startsWith(" \u2067")).toBe(true); + expect(rtlLine?.trimEnd().endsWith("\u2069")).toBe(true); + expect(rtlLine?.endsWith(" ")).toBe(true); + expect(targets.length).toBeGreaterThan(1); + expect(targets.every((target) => target === url)).toBe(true); }); }); diff --git a/src/tui/components/hyperlink-markdown.ts b/src/tui/components/hyperlink-markdown.ts index 34fd73a6061d..18a64836f97b 100644 --- a/src/tui/components/hyperlink-markdown.ts +++ b/src/tui/components/hyperlink-markdown.ts @@ -7,6 +7,14 @@ import type { } from "@earendil-works/pi-tui"; import { Markdown } from "@earendil-works/pi-tui"; import { addOsc8Hyperlinks, extractUrls } from "../osc8-hyperlinks.js"; +import { isolateRtlRenderedLine, sanitizeMarkdownSource } from "../tui-formatters.js"; + +function sanitizeMarkdownDisplayText(text: string): string { + if (!text) { + return text; + } + return sanitizeMarkdownSource(text) || "(no output)"; +} /** * Wrapper around pi-tui's Markdown component that adds OSC 8 terminal @@ -25,17 +33,19 @@ export class HyperlinkMarkdown implements Component { defaultTextStyle?: DefaultTextStyle, options?: MarkdownOptions, ) { - this.inner = new Markdown(text, paddingX, paddingY, theme, defaultTextStyle, options); - this.urls = extractUrls(text); + const displayText = sanitizeMarkdownDisplayText(text); + this.inner = new Markdown(displayText, paddingX, paddingY, theme, defaultTextStyle, options); + this.urls = extractUrls(displayText); } render(width: number): string[] { - return addOsc8Hyperlinks(this.inner.render(width), this.urls); + return addOsc8Hyperlinks(this.inner.render(width), this.urls).map(isolateRtlRenderedLine); } setText(text: string): void { - this.inner.setText(text); - this.urls = extractUrls(text); + const displayText = sanitizeMarkdownDisplayText(text); + this.inner.setText(displayText); + this.urls = extractUrls(displayText); } invalidate(): void { diff --git a/src/tui/components/searchable-select-list.test.ts b/src/tui/components/searchable-select-list.test.ts index 21d881c89f35..0d8d067d2046 100644 --- a/src/tui/components/searchable-select-list.test.ts +++ b/src/tui/components/searchable-select-list.test.ts @@ -332,11 +332,8 @@ describe("SearchableSelectList", () => { it("discards compiled regexes from previous searches", () => { const queryLength = 300; - const list = new SearchableSelectList( - [{ value: "match", label: "a".repeat(queryLength) }], - 5, - mockTheme, - ); + const label = "a".repeat(queryLength); + const list = new SearchableSelectList([{ value: "match", label }], 5, mockTheme); for (let index = 0; index < queryLength; index += 1) { list.handleInput("a"); @@ -345,7 +342,8 @@ describe("SearchableSelectList", () => { const regexCache = (list as unknown as { regexCache: Map }).regexCache; expect(regexCache.size).toBe(1); - expect(list.render(queryLength + 10).join("\n")).toContain(`*${"a".repeat(queryLength)}*`); + expect(regexCache.has(label)).toBe(true); + expect(list.render(queryLength + 10).join("\n")).toContain(`*${label}*`); }); it("shows no match message when filter yields no results", () => { @@ -400,6 +398,52 @@ describe("SearchableSelectList", () => { expect(selectedValue).toBe("anthropic/claude-3-opus"); }); + it("sanitizes rendered fields before applying trusted highlighting", () => { + const attacks = [ + "\u001b[38;5;201m", + "\u001b[3J", + "\u001b]0;search-title\u0007", + "\u001b]52;c;search-clipboard\u0007", + "\u009b2K", + "\u009d0;search-c1-title\u009c", + ]; + const rawValue = `selector-value-start${attacks[1]}selector-value-end\r\nمرحبا\tשלום`; + const description = `selector-description-start${attacks[3]}selector-description-end\n東京`; + const list = new SearchableSelectList( + [ + { + value: rawValue, + label: attacks.join(""), + description, + searchText: "selector-target", + }, + ], + 5, + ansiHighlightTheme, + ); + let selectedValue: string | undefined; + list.onSelect = (item) => { + selectedValue = item.value; + }; + + typeInput(list, "selector"); + const rendered = list.render(160).join("\n"); + const plainRendered = stripAnsi(rendered); + + expect(rendered).toContain("\u001b[31mselector\u001b[0m-value-start"); + expect(plainRendered).toContain("selector-description-startselector-description-end 東京"); + expect(plainRendered).toContain("مرحبا שלום"); + expect(plainRendered).toContain("\u2067"); + expect(plainRendered).toContain("\u2069"); + for (const attack of attacks) { + expect(rendered).not.toContain(attack); + } + expect(rendered).not.toContain("selector-value-end\r\nمرحبا\tשלום"); + + list.handleInput("\r"); + expect(selectedValue).toBe(rawValue); + }); + it("calls onCancel when escape is pressed", () => { const list = new SearchableSelectList(testItems, 5, mockTheme); let cancelled = false; diff --git a/src/tui/components/searchable-select-list.ts b/src/tui/components/searchable-select-list.ts index c1fe7c400f82..094565405646 100644 --- a/src/tui/components/searchable-select-list.ts +++ b/src/tui/components/searchable-select-list.ts @@ -14,6 +14,7 @@ import { import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; import { stripAnsi } from "../../../packages/terminal-core/src/ansi.js"; +import { sanitizeRenderableLine } from "../tui-formatters.js"; const ANSI_ESCAPE = String.fromCharCode(27); const ANSI_SGR_REGEX = new RegExp(`${ANSI_ESCAPE}\\[[0-9;]*m`, "g"); @@ -279,9 +280,12 @@ export class SearchableSelectList implements Component, Focusable { ): string { const prefix = isSelected ? "→ " : " "; const prefixWidth = prefix.length; - const displayValue = this.getItemLabel(item); + const displayValue = + sanitizeRenderableLine(this.getItemLabel(item)) || + sanitizeRenderableLine(item.value) || + "(unnamed)"; - const description = item.description; + const description = sanitizeRenderableLine(item.description ?? ""); if (description) { const descriptionLayout = this.getDescriptionLayout(width, prefixWidth); if (descriptionLayout) { diff --git a/src/tui/components/tool-execution.test.ts b/src/tui/components/tool-execution.test.ts index 164c78a5e1e9..41b8f42a649b 100644 --- a/src/tui/components/tool-execution.test.ts +++ b/src/tui/components/tool-execution.test.ts @@ -1,5 +1,6 @@ import { visibleWidth } from "@earendil-works/pi-tui"; import { describe, expect, it } from "vitest"; +import { splitAnsiSegments } from "../../../packages/terminal-core/src/ansi-sequences.js"; import { normalizeTestText } from "../../../test/helpers/normalize-text.js"; import { ToolExecutionComponent } from "./tool-execution.js"; @@ -82,4 +83,66 @@ describe("ToolExecutionComponent", () => { expect(component.render(80).map(normalizeTestText).join("\n")).toContain("tool output line 30"); }); + + it("sanitizes a complete OSC before applying the collapsed preview budget", () => { + const payload = "T08_OSC52_PREVIEW_SECRET"; + const prefix = `${"x ".repeat(112)}x`; + const attack = `\x1b]52;c;${payload}\x07`; + const { lines } = renderToolOutput(`${prefix}${attack} visible tail`, 20); + const raw = lines.join("\n"); + + expect(raw).not.toContain(payload); + expect(raw).not.toContain("]52;c;"); + expect(raw).not.toContain("\x1b]52"); + }); + + it.each([ + { route: "live", complete: false }, + { route: "history", complete: true }, + ])("sanitizes a hostile $route tool title before trusted styling", ({ complete }) => { + const attack = "\x1b]52;c;T08TOOLCLIPBOARD\x07"; + const toolName = `T08TOOLA${attack}T08TOOLB\r\nمرحبا\tשלום`; + const component = new ToolExecutionComponent(toolName, {}); + if (complete) { + component.setResult({ content: [] }); + } + + const raw = component.render(120).join("\n"); + const rendered = normalizeTestText(raw); + expect(rendered).toContain("T08TOOLAT08TOOLB"); + expect(rendered).toContain("مرحبا שלום"); + expect(raw).toContain("\u2067"); + expect(raw).toContain("\u2069"); + expect(raw).not.toContain(attack); + expect(raw).not.toContain("\r\nمرحبا\tשלום"); + }); + + it("parses sanitized RTL tool Markdown before isolating rendered OSC8 lines", () => { + const attack = "\x1b]52;c;T08_TOOL_RESULT\x07"; + const url = "https://example.test/tui/tool-result"; + const source = `\u202e# مرحبا\u202c\n\n\u200f> שלום\n\n\u2066- عنصر ${url}\u2069${attack}`; + const { lines } = renderToolOutput(source, 120); + const raw = lines.join("\n"); + const normalized = normalizeTestText(raw).replace(/[\u2067\u2069]/gu, ""); + const linkedRtl = lines.find((line) => line.includes("عنصر") && line.includes("\x1b]8;;")); + const targets = splitAnsiSegments(raw).flatMap((segment) => { + if (segment.kind !== "ansi" || !segment.value.startsWith("\x1b]8;;")) { + return []; + } + const end = segment.value.endsWith("\x1b\\") ? -2 : -1; + const target = segment.value.slice("\x1b]8;;".length, end); + return target ? [target] : []; + }); + + expect(normalized).toContain("مرحبا"); + expect(normalized).not.toContain("# مرحبا"); + expect(normalized).toContain("│ שלום"); + expect(normalized).toContain("- عنصر"); + expect(raw).not.toContain(attack); + expect(raw).not.toMatch(/[\u200f\u202e\u2066]/u); + expect(targets.length).toBeGreaterThan(0); + expect(targets.every((target) => target === url)).toBe(true); + expect(linkedRtl?.indexOf("\u2067")).toBeLessThan(linkedRtl?.indexOf("\x1b]8;;") ?? -1); + expect(linkedRtl?.indexOf("\u2069")).toBeGreaterThan(linkedRtl?.lastIndexOf("\x1b]8;;") ?? -1); + }); }); diff --git a/src/tui/components/tool-execution.ts b/src/tui/components/tool-execution.ts index eded1e6888d0..97c02f169462 100644 --- a/src/tui/components/tool-execution.ts +++ b/src/tui/components/tool-execution.ts @@ -1,9 +1,10 @@ // Tool execution component renders tool call status and output in the TUI. -import { Box, Container, Markdown, Spacer, Text, truncateToWidth } from "@earendil-works/pi-tui"; +import { Box, Container, Spacer, Text, truncateToWidth } from "@earendil-works/pi-tui"; import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import { formatToolDetail, resolveToolDisplay } from "../../agents/tool-display.js"; import { markdownTheme, theme } from "../theme/theme.js"; -import { sanitizeRenderableText } from "../tui-formatters.js"; +import * as tuiFormatters from "../tui-formatters.js"; +import { HyperlinkMarkdown } from "./hyperlink-markdown.js"; // Rendering model for live tool calls in the chat log. type ToolResultContent = { @@ -24,16 +25,17 @@ const MAX_PREVIEW_CHARS = PREVIEW_LINES * 256; // Bound the actual wrapped Markdown, not just source newlines: a single long // tool-output line can otherwise produce thousands of rows and stall the TUI. -class ToolOutputComponent extends Markdown { +class ToolOutputComponent extends HyperlinkMarkdown { private sourceText = ""; private renderedSource: string | undefined; private expanded = false; override setText(text: string): void { - if (this.sourceText === text) { + const sourceText = tuiFormatters.sanitizeMarkdownSource(text); + if (this.sourceText === sourceText) { return; } - this.sourceText = text; + this.sourceText = sourceText; this.renderedSource = undefined; super.invalidate(); } @@ -75,13 +77,13 @@ function formatArgs(toolName: string, args: unknown): string { const display = resolveToolDisplay({ name: toolName, args }); const detail = formatToolDetail(display); if (detail) { - return sanitizeRenderableText(detail); + return tuiFormatters.sanitizeRenderableText(detail); } if (!args || typeof args !== "object") { return ""; } try { - return sanitizeRenderableText(JSON.stringify(args)); + return tuiFormatters.sanitizeRenderableText(JSON.stringify(args)); } catch { return ""; } @@ -95,7 +97,7 @@ function extractText(result?: ToolResult): string { const lines: string[] = []; for (const entry of result.content) { if (entry.type === "text" && entry.text) { - lines.push(sanitizeRenderableText(entry.text)); + lines.push(entry.text); } else if (entry.type === "image") { const mime = entry.mimeType ?? "image"; const size = entry.bytes ? ` ${Math.round(entry.bytes / 1024)}kb` : ""; @@ -176,7 +178,9 @@ export class ToolExecutionComponent extends Container { name: this.toolName, args: this.args, }); - const title = `${display.emoji} ${display.label}${this.isPartial ? " (running)" : ""}`; + const title = tuiFormatters.sanitizeRenderableLine( + `${display.emoji} ${display.label}${this.isPartial ? " (running)" : ""}`, + ); this.header.setText(theme.toolTitle(theme.bold(title))); const argLine = formatArgs(this.toolName, this.args); diff --git a/src/tui/osc8-hyperlinks.test.ts b/src/tui/osc8-hyperlinks.test.ts index 66e04d62afa0..653eb7b1e55d 100644 --- a/src/tui/osc8-hyperlinks.test.ts +++ b/src/tui/osc8-hyperlinks.test.ts @@ -8,6 +8,12 @@ describe("extractUrls", () => { expect(urls).toEqual(["https://example.com"]); }); + it("stops bare URLs before bidi formatting controls", () => { + const url = "https://example.com/path"; + expect(extractUrls(`مرحبا ${url}\u2069`)).toEqual([url]); + expect(extractUrls(`مرحبا ${url}\u200f`)).toEqual([url]); + }); + it("extracts multiple bare URLs", () => { const urls = extractUrls("Visit https://foo.com and http://bar.com"); expect(urls).toContain("https://foo.com"); @@ -102,6 +108,13 @@ describe("addOsc8Hyperlinks", () => { expect(result[0]).toBe(`Visit \x1b]8;;${url}\x07${url}\x1b]8;;\x07 for info`); }); + it("keeps bidi isolation outside the exact OSC 8 target", () => { + const url = "https://example.com/path"; + expect(addOsc8Hyperlinks([`\u2067مرحبا ${url}\u2069`], [url])).toEqual([ + `\u2067مرحبا \x1b]8;;${url}\x07${url}\x1b]8;;\x07\u2069`, + ]); + }); + it("wraps a URL broken across two lines", () => { const fullUrl = "https://example.com/very/long/path/to/resource"; const lines = ["https://example.com/very/long/pa", "th/to/resource"]; diff --git a/src/tui/osc8-hyperlinks.ts b/src/tui/osc8-hyperlinks.ts index 5e47b1d56ee8..a0ad0c68930f 100644 --- a/src/tui/osc8-hyperlinks.ts +++ b/src/tui/osc8-hyperlinks.ts @@ -10,7 +10,8 @@ const OSC8_START_RE = new RegExp(`^${OSC8_PATTERN}`); /** Allow one level of balanced parentheses inside a URL so markdown link * targets like `https://en.wikipedia.org/wiki/URL_(disambiguation)` are * fully captured instead of truncated at the first `)`. */ -const URL_PATH_WITH_PARENS = /https?:\/\/[^()\s<>]+(?:\([^()\s<>]*\)[^()\s<>]*)*/g; +const URL_PATH_WITH_PARENS = + /https?:\/\/[^()\s<>\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]+(?:\([^()\s<>\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]*\)[^()\s<>\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]*)*/g; /** Strip the suffix starting at a `)` without a matching `(` in the URL. * Bare URLs in prose can pick up a trailing `)` that belongs to surrounding @@ -61,7 +62,8 @@ export function extractUrls(markdown: string): string[] { // Bare URLs (remove markdown links first to avoid double-matching) const stripped = markdown.replace(mdLinkRe, ""); - const bareRe = /https?:\/\/(?:\[[0-9a-f:.]+\](?::\d+)?[^\s\]>]*|[^\s[\]>]+)/gi; + const bareRe = + /https?:\/\/(?:\[[0-9a-f:.]+\](?::\d+)?[^\s\]>\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]*|[^\s[\]>\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]+)/gi; while ((m = bareRe.exec(stripped)) !== null) { const url = trimUnbalancedTrailingParens(m[0]); if (hasUrlContent(url)) { @@ -126,7 +128,8 @@ function findUrlRanges( } // Find new URL starts in visible text - const urlRe = /https?:\/\/(?:\[[0-9a-f:.]+\](?::\d+)?[^\s\]>]*|[^\s[\]>]*)/gi; + const urlRe = + /https?:\/\/(?:\[[0-9a-f:.]+\](?::\d+)?[^\s\]>\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]*|[^\s[\]>\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]*)/gi; urlRe.lastIndex = searchFrom; let match: RegExpExecArray | null; @@ -147,7 +150,10 @@ function findUrlRanges( if (!hasUnpunctuatedSchemeAtLineEnd) { continue; } - const nextToken = nextVisibleText?.trimStart().match(/^[^\s\]>]+/)?.[0] ?? ""; + const nextToken = + nextVisibleText + ?.trimStart() + .match(/^[^\s\]>\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]+/)?.[0] ?? ""; const nextFragment = trimUnbalancedTrailingParens(nextToken); for (const known of knownUrls) { if (!known.startsWith(fragment)) { @@ -242,7 +248,7 @@ function applyOsc8Ranges(line: string, ranges: UrlRange[]): string { // Existing OSC 8 sequence (pass through) const osc = line.slice(i).match(OSC8_START_RE); if (osc) { - result += osc[0]; + result += osc[0].replace(/[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, ""); i += osc[0].length; continue; } diff --git a/src/tui/tui-autocomplete.test.ts b/src/tui/tui-autocomplete.test.ts new file mode 100644 index 000000000000..d45ebd8b3753 --- /dev/null +++ b/src/tui/tui-autocomplete.test.ts @@ -0,0 +1,109 @@ +import type { AutocompleteItem, AutocompleteProvider } from "@earendil-works/pi-tui"; +import { describe, expect, it, vi } from "vitest"; +import { sanitizeAutocompleteProvider } from "./tui-autocomplete.js"; + +describe("sanitizeAutocompleteProvider", () => { + it("omits unsafe values while sanitizing safe display copies", async () => { + const safe: AutocompleteItem = { + value: "safe-value", + label: `label\x1b]52;c;Y2xpcGJvYXJk\x07\r\nمرحبا`, + description: "description\u009b31munsafe\u009b0m\tשלום", + }; + const unsafe: AutocompleteItem = { value: "raw\x1b[31m-value", label: "unsafe" }; + const applyCompletion = vi.fn(() => ({ + lines: [safe.value], + cursorLine: 0, + cursorCol: safe.value.length, + })); + const inner: AutocompleteProvider = { + getSuggestions: vi.fn(async () => ({ items: [unsafe, safe], prefix: "/" })), + applyCompletion, + }; + const provider = sanitizeAutocompleteProvider(inner); + + const suggestions = await provider.getSuggestions(["/"], 0, 1, { + signal: new AbortController().signal, + }); + const displayItem = suggestions?.items[0]; + + expect(displayItem).toEqual({ + value: safe.value, + label: "\u2067label مرحبا\u2069", + description: "\u2067descriptionunsafe שלום\u2069", + }); + expect(suggestions?.items).toHaveLength(1); + + provider.applyCompletion(["/"], 0, 1, displayItem!, "/"); + expect(applyCompletion).toHaveBeenCalledWith(["/"], 0, 1, safe, "/"); + }); + + it("preserves exact safe RTL paths and delegates file-completion triggers", async () => { + const value = "/tmp/مرحبا-東京/"; + const original = { value, label: value }; + const applyCompletion = vi.fn(() => ({ + lines: [value], + cursorLine: 0, + cursorCol: value.length, + })); + const inner: AutocompleteProvider = { + triggerCharacters: ["@"], + getSuggestions: vi.fn(async () => ({ + items: [original], + prefix: "@", + })), + applyCompletion, + shouldTriggerFileCompletion: vi.fn(() => true), + }; + const provider = sanitizeAutocompleteProvider(inner); + + const suggestions = await provider.getSuggestions(["@"], 0, 1, { + signal: new AbortController().signal, + }); + + expect(suggestions?.items[0]).toEqual({ + value, + label: `\u2067${value}\u2069`, + }); + provider.applyCompletion(["@"], 0, 1, suggestions!.items[0]!, "@"); + expect(applyCompletion).toHaveBeenCalledWith(["@"], 0, 1, original, "@"); + expect(provider.triggerCharacters).toEqual(["@"]); + expect(provider.shouldTriggerFileCompletion?.(["@"], 0, 1)).toBe(true); + }); + + it("returns null when every completion value is unsafe", async () => { + const inner: AutocompleteProvider = { + getSuggestions: vi.fn(async () => ({ + items: [{ value: "bad\tvalue", label: "bad" }], + prefix: "/", + })), + applyCompletion: vi.fn(() => ({ lines: [], cursorLine: 0, cursorCol: 0 })), + }; + const provider = sanitizeAutocompleteProvider(inner); + + await expect( + provider.getSuggestions(["/"], 0, 1, { signal: new AbortController().signal }), + ).resolves.toBeNull(); + }); + + it("falls back to a visible sanitized value and omits empty descriptions", async () => { + const original: AutocompleteItem = { + value: "fallback-value", + label: "\x1b]0;hidden\x07", + description: "\u009b31m\u009b0m", + }; + const inner: AutocompleteProvider = { + getSuggestions: vi.fn(async () => ({ items: [original], prefix: "/" })), + applyCompletion: vi.fn(() => ({ lines: [original.value], cursorLine: 0, cursorCol: 0 })), + }; + const provider = sanitizeAutocompleteProvider(inner); + + const suggestions = await provider.getSuggestions(["/"], 0, 1, { + signal: new AbortController().signal, + }); + + expect(suggestions?.items[0]).toEqual({ + value: original.value, + label: "fallback-value", + }); + }); +}); diff --git a/src/tui/tui-autocomplete.ts b/src/tui/tui-autocomplete.ts new file mode 100644 index 000000000000..98833028d0f9 --- /dev/null +++ b/src/tui/tui-autocomplete.ts @@ -0,0 +1,50 @@ +import type { AutocompleteItem, AutocompleteProvider } from "@earendil-works/pi-tui"; +import { isTerminalSafeAutocompleteValue, sanitizeRenderableLine } from "./tui-formatters.js"; + +const originalSafeItem = Symbol("originalSafeItem"); +/** Sanitize autocomplete presentation and omit values unsafe for editor rendering. */ +export function sanitizeAutocompleteProvider(inner: AutocompleteProvider): AutocompleteProvider { + return { + triggerCharacters: inner.triggerCharacters, + async getSuggestions(...args) { + const suggestions = await inner.getSuggestions(...args); + if (!suggestions) { + return null; + } + const safeItems = suggestions.items.filter((item) => + isTerminalSafeAutocompleteValue(item.value), + ); + if (safeItems.length === 0) { + return null; + } + return { + ...suggestions, + items: Array.from(safeItems, (item) => { + const { description: rawDescription, ...displayFields } = item; + const label = + sanitizeRenderableLine(item.label) || sanitizeRenderableLine(item.value) || "(unnamed)"; + const description = + rawDescription === undefined ? undefined : sanitizeRenderableLine(rawDescription); + const displayItem = { + ...displayFields, + label, + ...(description ? { description } : {}), + }; + return Object.defineProperty(displayItem, originalSafeItem, { value: item }); + }), + }; + }, + applyCompletion(lines, cursorLine, cursorCol, item, prefix) { + return inner.applyCompletion( + lines, + cursorLine, + cursorCol, + (Reflect.get(item, originalSafeItem) as AutocompleteItem | undefined) ?? item, + prefix, + ); + }, + shouldTriggerFileCompletion: inner.shouldTriggerFileCompletion + ? (...args) => inner.shouldTriggerFileCompletion!(...args) + : undefined, + }; +} diff --git a/src/tui/tui-formatters.test.ts b/src/tui/tui-formatters.test.ts index 7161e31eab7f..9d30915d696c 100644 --- a/src/tui/tui-formatters.test.ts +++ b/src/tui/tui-formatters.test.ts @@ -9,7 +9,11 @@ import { extractThinkingFromMessage, formatTuiFooter, formatTuiErrorMessage, + isolateRtlRenderedLine, + isTerminalSafeAutocompleteValue, isCommandMessage, + sanitizeMarkdownSource, + sanitizeRenderableLine, sanitizeRenderableText, } from "./tui-formatters.js"; @@ -62,6 +66,37 @@ describe("formatTuiFooter", () => { expect(new Text(summary, 1, 0).render(48).every((line) => visibleWidth(line) <= 48)).toBe(true); }); + it("sanitizes terminal controls and collapses footer fields to one line", () => { + const attacks = [ + "\u001b[38;5;201m", + "\u001b[3J", + "\u001b]0;footer-title\u0007", + "\u001b]52;c;footer-clipboard\u0007", + "\u009b2K", + "\u009d0;footer-c1-title\u009c", + ]; + const footer = formatTuiFooter({ + agentLabel: `agent-start${attacks[0]}agent-end\nمرحبا`, + sessionLabel: `session-start${attacks[2]}session-end\r\nשלום`, + sessionInfo: { + model: `provider/model-start${attacks[1]}middle${attacks[3]}${attacks[4]}${attacks[5]}model-end\tUnicode`, + }, + deliver: true, + }); + + expect(footer).toContain("agent-startagent-end"); + expect(footer).toContain("مرحبا"); + expect(footer).toContain("session-startsession-end"); + expect(footer).toContain("שלום"); + expect(footer).toContain("model-startmiddlemodel-end Unicode"); + expect(footer).toContain("\u2067"); + expect(footer).toContain("\u2069"); + expect(footer).not.toMatch(/[\r\n\t]/u); + for (const attack of attacks) { + expect(footer).not.toContain(attack); + } + }); + it("renders active goal usage", () => { const footer = formatTuiFooter({ agentLabel: "Main", @@ -730,6 +765,11 @@ describe("sanitizeRenderableText", () => { expect(sanitized).toBe(input); }); + it("removes untrusted bidi overrides before adding trusted RTL isolation", () => { + expect(sanitizeRenderableText("\u202eمرحبا\u202c")).toBe("\u2067مرحبا\u2069"); + expect(sanitizeRenderableText("\u061cمرحبا\u200f")).toBe("\u2067مرحبا\u2069"); + }); + it("preserves long camelCase identifiers wrapped in inline code spans (#48432)", () => { const input = "- `requireConfirmationForMutatingActions: false`"; const sanitized = sanitizeRenderableText(input); @@ -859,3 +899,64 @@ describe("sanitizeRenderableText", () => { expect(sanitized).toContain("[binary data omitted]"); }); }); + +describe("Markdown display safety", () => { + it("strips hostile controls from source without adding directional isolates", () => { + const input = "\u202e# مرحبا\u202c\n\u009b31m> שלום\u009b0m"; + const sanitized = sanitizeMarkdownSource(input); + + expect(sanitized).toBe("# مرحبا\n> שלום"); + expect(sanitized).not.toMatch(/[\u2066-\u2069]/u); + }); + + it("isolates rendered RTL lines without changing visible width", () => { + const rendered = "\x1b[1mمرحبا\x1b[0m"; + const isolated = isolateRtlRenderedLine(rendered); + + expect(isolated).toBe(`\u2067${rendered}\u2069`); + expect(visibleWidth(isolated)).toBe(visibleWidth(rendered)); + }); + + it("keeps rendered padding outside RTL isolates", () => { + const rendered = " \x1b[1mمرحبا\x1b[0m "; + const isolated = isolateRtlRenderedLine(rendered); + + expect(isolated).toBe(` \u2067\x1b[1mمرحبا\x1b[0m\u2069 `); + expect(visibleWidth(isolated)).toBe(visibleWidth(rendered)); + }); +}); + +describe("isTerminalSafeAutocompleteValue", () => { + it("accepts ordinary Unicode and rejects terminal or bidi controls", () => { + expect(isTerminalSafeAutocompleteValue("/tmp/مرحبا-東京.txt")).toBe(true); + for (const value of [ + "bad\x1b[31m", + "bad\tvalue", + "bad\u009bvalue", + "bad\u200evalue", + "bad\u202evalue", + ]) { + expect(isTerminalSafeAutocompleteValue(value)).toBe(false); + } + }); +}); + +describe("sanitizeRenderableLine", () => { + it("preserves RTL isolation while collapsing carriage returns, newlines, and tabs", () => { + expect(sanitizeRenderableLine("left\r\nمرحبا\tשלום right")).toBe( + "\u2067left مرحبا שלום right\u2069", + ); + }); + + it("preserves long exact labels without prose token splitting", () => { + const label = "a".repeat(300); + + expect(sanitizeRenderableLine(label)).toBe(label); + }); + + it("strips terminal controls and redacts binary-like lines before collapsing whitespace", () => { + const input = `safe\x1b]52;c;Y2xpcGJvYXJk\x07\r\n${"�".repeat(20)}\tمرحبا`; + + expect(sanitizeRenderableLine(input)).toBe("safe [binary data omitted]"); + }); +}); diff --git a/src/tui/tui-formatters.ts b/src/tui/tui-formatters.ts index ac059c3d1011..76992066a11b 100644 --- a/src/tui/tui-formatters.ts +++ b/src/tui/tui-formatters.ts @@ -26,7 +26,8 @@ const ALPHANUMERIC_RE = /[A-Za-z0-9]/; const TOKENISH_MIN_LENGTH = 24; const RTL_SCRIPT_RE = /[\u0590-\u08ff\ufb1d-\ufdff\ufe70-\ufefc]/; const CJK_SCRIPT_RE = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u; -const BIDI_CONTROL_RE = /[\u202a-\u202e\u2066-\u2069]/; +const BIDI_CONTROL_RE = /[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/; +const BIDI_CONTROL_GLOBAL_RE = /[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g; const RTL_ISOLATE_START = "\u2067"; const RTL_ISOLATE_END = "\u2069"; // Fenced code blocks (``` or ~~~). Lazy on content; tolerates info string after @@ -62,7 +63,7 @@ export function formatTuiFooter(params: { const traceLabel = trace === "raw" ? "trace:raw" : trace === "on" ? "trace" : null; const reasoningLabel = reasoning === "on" ? "reasoning" : reasoning === "stream" ? "reasoning:stream" : null; - return [ + const footer = [ `agent ${params.agentLabel}`, `session ${params.sessionLabel}`, formatModelFooter({ model: sessionInfo.model, thinkingLevel: params.thinkingLevel }), @@ -76,6 +77,7 @@ export function formatTuiFooter(params: { ] .filter(Boolean) .join(" | "); + return sanitizeRenderableLine(footer); } function hasControlChars(text: string): boolean { @@ -106,6 +108,33 @@ function stripControlChars(text: string): string { return sanitized; } +function sanitizeTerminalControlsAndBinary(text: string): string { + const hasAnsi = text.includes("\u001b") || text.includes("\u009b") || text.includes("\u009d"); + const withoutAnsi = hasAnsi ? stripAnsi(text) : text; + const withoutControlChars = hasControlChars(withoutAnsi) + ? stripControlChars(withoutAnsi) + : withoutAnsi; + const withoutBidiControls = BIDI_CONTROL_RE.test(withoutControlChars) + ? withoutControlChars.replace(BIDI_CONTROL_GLOBAL_RE, "") + : withoutControlChars; + return withoutBidiControls.includes("\uFFFD") + ? withoutBidiControls + .split("\n") + .map((line) => redactBinaryLikeLine(line)) + .join("\n") + : withoutBidiControls; +} + +export function isTerminalSafeAutocompleteValue(value: string): boolean { + for (const char of value) { + const code = char.charCodeAt(0); + if (code <= 0x1f || (code >= 0x7f && code <= 0x9f) || BIDI_CONTROL_RE.test(char)) { + return false; + } + } + return true; +} + function isCopySensitiveToken(token: string): boolean { const coreToken = token.replace(EDGE_PUNCTUATION_RE, ""); const candidate = coreToken || token; @@ -211,12 +240,23 @@ function redactBinaryLikeLine(line: string): string { } function isolateRtlLine(line: string): string { - if (!RTL_SCRIPT_RE.test(line) || BIDI_CONTROL_RE.test(line)) { + if (!RTL_SCRIPT_RE.test(line)) { return line; } return `${RTL_ISOLATE_START}${line}${RTL_ISOLATE_END}`; } +export function isolateRtlRenderedLine(line: string): string { + if (!RTL_SCRIPT_RE.test(stripAnsi(line))) { + return line; + } + const padding = line.match(/^(\s*)(.*\S)(\s*)$/u); + if (!padding) { + return line; + } + return `${padding[1]}${RTL_ISOLATE_START}${padding[2]}${RTL_ISOLATE_END}${padding[3]}`; +} + function applyRtlIsolation(text: string): string { if (!RTL_SCRIPT_RE.test(text)) { return text; @@ -227,35 +267,33 @@ function applyRtlIsolation(text: string): string { .join("\n"); } -export function sanitizeRenderableText(text: string): string { +export function sanitizeMarkdownSource(text: string): string { if (!text) { return text; } - const hasAnsi = text.includes("\u001b") || text.includes("\u009b") || text.includes("\u009d"); - const hasReplacementChars = text.includes("\uFFFD"); const hasLongTokens = LONG_TOKEN_TEST_RE.test(text); - const hasControls = hasControlChars(text); - if (!hasAnsi && !hasReplacementChars && !hasLongTokens && !hasControls) { - return applyRtlIsolation(text); + const controlSafe = sanitizeTerminalControlsAndBinary(text); + if (controlSafe === text && !hasLongTokens) { + return text; } - const withoutAnsi = hasAnsi ? stripAnsi(text) : text; - const withoutControlChars = hasControls ? stripControlChars(withoutAnsi) : withoutAnsi; - const redacted = hasReplacementChars - ? withoutControlChars - .split("\n") - .map((line) => redactBinaryLikeLine(line)) - .join("\n") - : withoutControlChars; - const tokenSafe = LONG_TOKEN_TEST_RE.test(redacted) - ? transformOutsideCode(redacted, (segment) => + return LONG_TOKEN_TEST_RE.test(controlSafe) + ? transformOutsideCode(controlSafe, (segment) => LONG_TOKEN_TEST_RE.test(segment) ? segment.replace(LONG_TOKEN_RE, normalizeLongTokenForDisplay) : segment, ) - : redacted; - return applyRtlIsolation(tokenSafe); + : controlSafe; +} + +export function sanitizeRenderableText(text: string): string { + return applyRtlIsolation(sanitizeMarkdownSource(text)); +} + +export function sanitizeRenderableLine(text: string): string { + const line = sanitizeTerminalControlsAndBinary(text).replace(/\s+/gu, " ").trim(); + return applyRtlIsolation(line); } /** Render error causes without exposing secrets or terminal control sequences. */ diff --git a/src/tui/tui-pty-harness-assertion-test-support.test.ts b/src/tui/tui-pty-harness-assertion-test-support.test.ts new file mode 100644 index 000000000000..dc59ed0d50aa --- /dev/null +++ b/src/tui/tui-pty-harness-assertion-test-support.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; +import * as oracle from "./tui-pty-harness-assertion-test-support.js"; + +const FRAME_START = "\x1b[?2026h"; +const FRAME_END = "\x1b[?2026l"; +const EXPECTED = "T08A safe T08B"; +const MARKERS = ["T08A", "T08B"]; +const TERMINAL = { cols: 32, rows: 4 }; +const parse = (raw: string, dimensions = TERMINAL) => oracle.synchronizedFrameRows(raw, dimensions); +const frame = (text: string) => `${FRAME_START}${text}${FRAME_END}`; +const hasExpected = (raw: string, dimensions = TERMINAL) => + oracle.hasSynchronizedFrameRow(raw, MARKERS, EXPECTED, dimensions); +const hasHistoricalExpected = (raw: string, dimensions = TERMINAL) => + oracle.hasHistoricalSynchronizedFrameRow(raw, MARKERS, EXPECTED, dimensions); + +describe("hasSynchronizedFrameRow", () => { + it("requires exact single-space text and all markers on one completed row", () => { + expect(hasExpected(frame(EXPECTED))).toBe(true); + expect(hasExpected(frame("T08A safe\r\nT08B"))).toBe(false); + expect(hasExpected(frame("T08A\tsafe T08B"))).toBe(false); + expect( + oracle.hasSynchronizedFrameRow( + frame("\x1b[2J\x1b[H1234567\tsafe T08B"), + ["1234567", "T08B"], + "1234567 safe T08B", + TERMINAL, + ), + ).toBe(false); + expect( + oracle.hasSynchronizedFrameRow( + frame("\x1b[2J\x1b[H1234567X\b safe T08B"), + ["1234567", "T08B"], + "1234567 safe T08B", + TERMINAL, + ), + ).toBe(false); + expect(hasExpected(frame("T08A safe T08B"))).toBe(false); + expect(parse(frame("界X\r\x1b[2G?"))[0]).toEqual([" ?X"]); + expect(parse(frame("界X\r\x1b[2G\x1b[K"))[0]).toEqual([""]); + expect(parse(frame("\u2067RTL\u2069"))[0]).toEqual(["RTL"]); + expect(parse(frame(`${"x".repeat(32)}\x1b[3Jy`), { cols: 32, rows: 2 })[0]).toEqual([ + "x".repeat(32), + "y", + ]); + }); + it("rejects terminal row reconstruction false positives", () => { + expect(hasExpected(frame(`stale\x1b[2J\x1b[HT08A\x1b[6Gsafe\x1b[11GT08B`))).toBe(true); + expect(hasExpected(`${EXPECTED}${frame("\x1b[H\x1b[JT08A\x1b[6Gsafe\x1b[11GT08B")}`)).toBe( + true, + ); + expect(hasExpected(`legacy ${frame(`\x1b[8G${EXPECTED}`)}`)).toBe(true); + expect(hasExpected(`${frame(EXPECTED)}${frame("")}`)).toBe(true); + expect(hasExpected(`${frame(EXPECTED)}${frame("\x1b[31m")}`)).toBe(true); + expect(hasExpected(`${frame(EXPECTED)}${frame("\x1b[Bunrelated")}`)).toBe(true); + expect(hasExpected(`${frame(EXPECTED)}\x1b[A\x1b[1G`)).toBe(true); + for (const raw of [ + frame("T08A safe\x1b[BT08B"), + frame(`${EXPECTED}\r\x1b[KT08A bad T08B`), + frame("T08AxsafexT08B\x1b[3J\x1b[HT08A\x1b[6Gsafe\x1b[11GT08B"), + `T08A safe\r\n\x1b[B${frame("T08B")}`, + `${EXPECTED}${frame("")}`, + `${frame(EXPECTED)}${frame("\x1b[2J")}`, + `${EXPECTED}${frame("T08A\x1b[6Gsafe\x1b[11GT08B")}`, + `${EXPECTED}\x1b[H\x1b[J${frame("T08A\x1b[6Gsafe\x1b[11GT08B")}`, + `${frame(EXPECTED)} unrelated`, + `${frame(EXPECTED)}\r`, + `${frame(EXPECTED)}\rT08A unsafe T08B`, + `${frame(EXPECTED)}\r\x1b[K`, + `${frame(EXPECTED)}\x1b[H\x1b[J`, + `${frame(EXPECTED)}\x1b[2J`, + `${frame(EXPECTED)}\rT08A unsafe T08B${frame("\x1b[31m")}`, + `${frame(EXPECTED)}\r\x1b[K${frame("\x1b[31m")}`, + `${frame(EXPECTED)}\x1b[2J${frame("\x1b[31m")}`, + ]) { + expect(hasExpected(raw)).toBe(false); + } + expect(hasExpected(frame(EXPECTED), { cols: EXPECTED.length, rows: 2 })).toBe(true); + expect(hasExpected(frame(EXPECTED), { cols: EXPECTED.length - 1, rows: 2 })).toBe(false); + expect(hasExpected(frame(`${EXPECTED}\r\nrow two\r\nrow three`), { cols: 32, rows: 2 })).toBe( + false, + ); + expect(hasExpected(`${frame(EXPECTED)}\r\nrow two\r\nrow three`, { cols: 32, rows: 2 })).toBe( + false, + ); + expect(hasHistoricalExpected(`${frame(EXPECTED)}\r\x1b[K`)).toBe(true); + expect(hasHistoricalExpected(`${frame(EXPECTED)}${frame("later")}`)).toBe(true); + expect(hasHistoricalExpected(`${EXPECTED}${frame("")}`)).toBe(false); + expect(hasHistoricalExpected(`${frame(EXPECTED)}${FRAME_START}later`)).toBe(false); + for (const control of "\x1b[A|\x1b[2B|\x1b[3G|\x1b[H|\x1b[J|\x1b[0J|\x1b[2J|\x1b[3J|\x1b[K|\x1b[0K|\x1b[2K|\x1b[m|\x1b[1;38;2;255;0;0m".split( + "|", + )) { + expect(hasExpected(frame(`${control}${EXPECTED}`))).toBe(true); + } + const lifecycle = + "\x1b[?25h\x1b[?25l\x1b[?2004h\x1b[?2004l\x1b[>7u\x1b[?u\x1b[c\x1b[4;2m\x1b[>4;0m\x1b]8;;\x07\x1b]8;;\x1b\\"; + expect(hasExpected(lifecycle + frame(EXPECTED))).toBe(true); + const osc8Bel = "\x1b]8;;https://example.test/path\x07"; + const osc8St = "\x1b]8;;https://example.test/path\x1b\\"; + expect( + hasExpected(frame(`${osc8Bel}${EXPECTED}\x1b]8;;\x07${osc8St}x\x1b]8;;\x1b\\\x1b]8;;\x07`)), + ).toBe(true); + for (const control of "\u009b31m|\x1b[3\t1m|\x1b[C|\x1b[D|\x1b[2;1H|\x1b[f|\x1b[n|\x1b[q|\x1b[c|\x1b[0c|\x1b[?2031h|\x1b[?2026h|\x1b[?2026l|\x1b[4h|\x1b[1J|\x1b[1K|\x1b[1 q|\x1b[1:2m|\x1b[33G|\x1b[9007199254740991B|\x1b]0;title\x07|\x1b]9;4;3\x07|\x1b]11;?\x07|\x1b]52;c;secret\x07|\x1b]1337;File=name=x\x07|\x1b]8;id=x;https://example.test\x07|\x1b]8;;ftp://example.test\x07|\u009d8;;https://example.test\x07|\x1b]8;;https://a.test\x07|\x1b]8;;https://a.test\x07\x1b]8;;https://b.test\x07|\x1bc|\x1b[?20\t26h|\x1b[?20\t26l|\v".split( + "|", + )) { + expect(() => parse(frame(`safe${control}`))).toThrow(); + } + expect(() => + parse(`\x1b]8;;https://example.test\x07outside\x1b]8;;\x07${frame("safe")}`), + ).toThrow(); + for (const suffix of "\x1b[39|\u009b39|\x1b[?2026|\x1b|\x1b]0;title\x1b|\u009d|\x1b]8;;https://example.test|\x1b]8;;\x1b".split( + "|", + )) { + expect(parse(frame("safe") + suffix)).toEqual([]); + } + expect(parse(frame("safe") + ["\x1b[39", "m"].join(""))).toEqual([["safe"]]); + const openFrame = `${FRAME_START}safe\x1b]8;;https://example.test\x07`; + expect(parse(openFrame)).toEqual([]); + const splitClose = `${openFrame}\x1b]8;;\x1b`; + expect(parse(splitClose)).toEqual([]); + expect(parse(`${splitClose}\\${FRAME_END}`)).toEqual([["safe"]]); + expect(() => parse(`${frame("safe")}\x1b]0;title\x1b\\`)).toThrow(); + }); +}); diff --git a/src/tui/tui-pty-harness-assertion-test-support.ts b/src/tui/tui-pty-harness-assertion-test-support.ts new file mode 100644 index 000000000000..cdbcff91fb13 --- /dev/null +++ b/src/tui/tui-pty-harness-assertion-test-support.ts @@ -0,0 +1,744 @@ +// Shared assertions and exercises for the fake-backend TUI PTY harness. +import { readFile } from "node:fs/promises"; +import { expect } from "vitest"; +import * as ansiSequences from "../../packages/terminal-core/src/ansi-sequences.js"; +import * as ansi from "../../packages/terminal-core/src/ansi.js"; +import { formatTuiFooter, sanitizeRenderableLine } from "./tui-formatters.js"; +import { + PtyTestScreen, + sleep, + type PtyRun, + type PtyTerminalDimensions, + waitFor, +} from "./tui-pty-test-support.js"; + +export type FixtureLogEntry = { method: string; payload?: unknown }; + +export const COMPACT_TERMINAL_SIZES = [ + [64, 18], + [68, 18], + [72, 20], + [80, 20], +] as const; + +export async function readFixtureLog(logPath: string): Promise { + try { + const text = await readFile(logPath, "utf8"); + return text + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line) as FixtureLogEntry); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return []; + } + throw error; + } +} + +export async function waitForFixtureLogEntry( + logPath: string, + predicate: (entry: FixtureLogEntry) => boolean, + timeoutMs: number, + readPtyOutput?: () => string, +) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const entries = await readFixtureLog(logPath); + const match = entries.find(predicate); + if (match) { + return match; + } + await sleep(25); + } + const entries = await readFixtureLog(logPath); + // A swallowed command leaves no RPC; its visible rejection survives only in the terminal. + const ptyOutput = readPtyOutput?.() ?? ""; + throw new Error( + `timed out waiting for fixture log entry\n${JSON.stringify(entries, null, 2)}\n${ptyOutput}`, + ); +} + +export function objectFieldEquals(entry: FixtureLogEntry, field: string, value: unknown) { + if (typeof entry.payload !== "object" || entry.payload === null) { + return false; + } + const payload = entry.payload as Record; + return Object.hasOwn(payload, field) && payload[field] === value; +} + +type StartTuiPtyFixture = Parameters[0]; +type StartedTuiPtyFixture = Awaited>; +type TerminalAttackPayload = { + text: string; + markers: string[]; + attacks: string[]; + expectedLine: string; +}; + +function buildCompactTerminalAttackPayload(tag: string, attack: string): TerminalAttackPayload { + const markers = [`${tag}a`, `${tag}b`, `${tag}c`, `${tag}d`]; + const lineBreakAttack = `\r\n${markers[2]}`; + const tabAttack = "\tשלום"; + return { + text: `${markers[0]}${attack}${markers[1]} café 東京 👩🏽‍💻${lineBreakAttack} مرحبا${tabAttack} ${markers[3]}`, + markers, + attacks: [attack, lineBreakAttack, tabAttack], + expectedLine: `${markers[0]}${markers[1]} café 東京 👩🏽‍💻 ${markers[2]} مرحبا שלום ${markers[3]}`, + }; +} + +function buildInlineTerminalAttackPayload(tag: string, attack: string): TerminalAttackPayload { + const markers = [`${tag}a`, `${tag}b`, `${tag}c`]; + return { + text: `${markers[0]}${attack}${markers[1]} café 東京 👩🏽‍💻 مرحبا שלום ${markers[2]}`, + markers, + attacks: [attack], + expectedLine: `${markers[0]}${markers[1]} café 東京 👩🏽‍💻 مرحبا שלום ${markers[2]}`, + }; +} + +const STALE_CELL_SENTINEL = "\u0000"; + +function assertEvidence(condition: boolean, message: string) { + if (!condition) { + throw new Error(message); + } +} + +const lifecycleCsiBody = /^(?:\?25[hl]|\?2004[hl]|>7u|\?u|c|4;[02]m)$/u; +const screenMutationCsiBody = /^(?:[02]?J|[02]?K)$/u; + +function assertAllowedCsi(screen: PtyTestScreen, value: string, controls: string[] = []) { + const body = value.startsWith("\x1b[") ? value.slice(2) : ""; + const move = body.match(/^([1-9]\d*)?([ABG])$/u); + const moveCount = Number(move?.[1] ?? "1"); + const allowed = + controls.length === 0 && + ((move !== null && + Number.isSafeInteger(moveCount) && + moveCount <= Math.max(screen.cols, screen.rows)) || + value === "\x1b[H" || + /^(?:0|2|3)?J$/u.test(body) || + /^(?:0|2)?K$/u.test(body) || + lifecycleCsiBody.test(body) || + value === "\x1b[?2026h" || + value === "\x1b[?2026l" || + /^(?:\d+(?:;\d+)*)?m$/u.test(body)); + assertEvidence(allowed, `unsupported CSI in TUI PTY evidence: ${JSON.stringify(value)}`); +} + +function applyScreenCsi(screen: PtyTestScreen, value: string, synchronized: boolean) { + if (synchronized && lifecycleCsiBody.test(value.slice(2))) { + throw new Error(`lifecycle CSI inside synchronized frame: ${JSON.stringify(value)}`); + } + screen.applyCsi(value, synchronized); +} + +function scanOsc(raw: string, bodyStart: number) { + const candidates: Array<[index: number, length: number]> = [ + [raw.indexOf("\x07", bodyStart), 1], + [raw.indexOf("\x1b\\", bodyStart), 2], + [raw.indexOf("\u009c", bodyStart), 1], + ]; + const terminator = candidates + .filter(([index]) => index >= 0) + .toSorted(([left], [right]) => left - right)[0]; + if (!terminator) { + return undefined; + } + const [index, length] = terminator; + const body = raw.slice(bodyStart, index); + if (raw[index] === "\u009c" || ansi.sanitizeForLog(body) !== body) { + throw new Error("unsupported terminal control in TUI PTY OSC evidence"); + } + return { body, end: index + length }; +} + +function assertAllowedOsc(body: string) { + const target = body.startsWith("8;;") ? body.slice(3) : undefined; + if ( + target === undefined || + (target !== "" && + (!/^https?:\/\/\S+$/u.test(target) || + ansi.sanitizeForLog(target) !== target || + !URL.canParse(target))) + ) { + throw new Error(`unsupported OSC in TUI PTY evidence: ${JSON.stringify(body)}`); + } + return target; +} + +function terminalOutputIsComplete(raw: string) { + const oscStart = Math.max(raw.lastIndexOf("\x1b]"), raw.lastIndexOf("\u009d")); + if (oscStart >= 0 && !scanOsc(raw, oscStart + (raw[oscStart] === "\x1b" ? 2 : 1))) { + return false; + } + const csiStart = Math.max(raw.lastIndexOf("\x1b["), raw.lastIndexOf("\u009b")); + const csi = csiStart >= 0 ? ansiSequences.scanAnsiCsiAt(raw, csiStart) : undefined; + return csi?.ended !== false && !raw.endsWith("\x1b"); +} + +type TerminalReplay = { + completedFrame: boolean; + matchedFrame: boolean; + osc8Open: boolean; + screen: PtyTestScreen; + synchronized: boolean; +}; + +function replayTerminalState( + raw: string, + dimensions: PtyTerminalDimensions, + framePredicate?: (screen: PtyTestScreen) => boolean, +): TerminalReplay | undefined { + const start = "\x1b[?2026h"; + const end = "\x1b[?2026l"; + const screen = new PtyTestScreen(dimensions); + let completedFrame = false; + let matchedFrame = false; + let synchronized = false; + let osc8Open = false; + if (!terminalOutputIsComplete(raw)) { + return undefined; + } + for (const segment of ansiSequences.splitAnsiSegments(raw)) { + if (segment.kind === "text") { + // pi-tui expands visible tabs and does not use literal HT/BS for output layout. + // Captured HT/BS bytes are invalid evidence, not terminal operations to replay. + if (segment.value.includes("\t") || segment.value.includes("\b")) { + return undefined; + } + if (!synchronized && completedFrame && segment.value) { + completedFrame = false; + } + screen.write(segment.value, synchronized); + } else if (segment.controls.length > 0 || !segment.value.startsWith("\x1b")) { + throw new Error("unsupported terminal sequence in TUI PTY evidence"); + } else if (segment.value === start) { + assertEvidence(!synchronized, "nested synchronized frame"); + completedFrame = false; + synchronized = true; + } else if (segment.value === end) { + assertEvidence(synchronized, "unmatched synchronized frame end"); + assertEvidence(!osc8Open, "unclosed OSC 8 hyperlink in synchronized frame"); + synchronized = false; + completedFrame = true; + matchedFrame ||= framePredicate?.(screen) ?? false; + } else if (segment.value.startsWith("\x1b]")) { + const target = assertAllowedOsc( + segment.value.slice(2, segment.value.endsWith("\x1b\\") ? -2 : -1), + ); + assertEvidence(synchronized || target === "", "OSC 8 open outside synchronized frame"); + if (!synchronized) { + continue; + } + assertEvidence( + target === "" || !osc8Open, + "unbalanced OSC 8 hyperlink in synchronized frame", + ); + osc8Open = target !== ""; + } else if (segment.value.startsWith("\x1b[")) { + assertAllowedCsi(screen, segment.value, segment.controls); + if (!synchronized && completedFrame && screenMutationCsiBody.test(segment.value.slice(2))) { + completedFrame = false; + } + applyScreenCsi(screen, segment.value, synchronized); + } else { + throw new Error(`unsupported ESC sequence in TUI PTY evidence: ${segment.value}`); + } + } + return { completedFrame, matchedFrame, osc8Open, screen, synchronized }; +} + +function parseTerminalState( + raw: string, + dimensions: PtyTerminalDimensions, +): PtyTestScreen | undefined { + const replay = replayTerminalState(raw, dimensions); + return replay && !replay.synchronized && !replay.osc8Open && replay.completedFrame + ? replay.screen + : undefined; +} + +export function synchronizedFrameRows(raw: string, dimensions: PtyTerminalDimensions): string[][] { + const screen = parseTerminalState(raw, dimensions); + if (!screen) { + return []; + } + const rows = screen.cells.map((row) => + row + .map((cell) => cell.text) + .join("") + .trimEnd(), + ); + while (rows.length > 1 && rows.at(-1) === "") { + rows.pop(); + } + return [rows]; +} + +function screenHasRow(screen: PtyTestScreen, predicate: (row: string) => boolean) { + return screen.cells.some((cells) => { + const authoredRow = cells + .map((cell) => (cell.text === "" || cell.authenticated ? cell.text : STALE_CELL_SENTINEL)) + .join("") + .trimEnd(); + return predicate(authoredRow); + }); +} + +function latestFrameHasRow( + raw: string, + dimensions: PtyTerminalDimensions, + predicate: (row: string) => boolean, +) { + const screen = parseTerminalState(raw, dimensions); + return screen ? screenHasRow(screen, predicate) : false; +} + +function terminalAttackRowMatches(markers: string[], expectedText: string, row: string) { + return markers.every((marker) => row.includes(marker)) && row.includes(expectedText); +} + +export function hasSynchronizedFrameRow( + raw: string, + markers: string[], + expectedText: string, + dimensions: PtyTerminalDimensions, +) { + return latestFrameHasRow(raw, dimensions, (row) => + terminalAttackRowMatches(markers, expectedText, row), + ); +} + +export function hasHistoricalSynchronizedFrameRow( + raw: string, + markers: string[], + expectedText: string, + dimensions: PtyTerminalDimensions, +) { + const replay = replayTerminalState(raw, dimensions, (screen) => + screenHasRow(screen, (row) => terminalAttackRowMatches(markers, expectedText, row)), + ); + return replay !== undefined && !replay.synchronized && !replay.osc8Open && replay.matchedFrame; +} + +async function assertTerminalAttackSanitized( + fixture: StartedTuiPtyFixture, + payload: TerminalAttackPayload, + timeoutMs: number, +) { + const observed = await fixture.run.waitForOutput(payload.markers.at(-1) ?? "", timeoutMs); + const visible = fixture.run.visibleOutput(); + expect(payload.markers.every((marker) => visible.includes(marker))).toBe(true); + if (!hasSynchronizedFrameRow(observed, payload.markers, payload.expectedLine, fixture.run)) { + await waitFor({ + timeoutMs, + read: () => { + const output = fixture.run.output(); + return hasSynchronizedFrameRow(output, payload.markers, payload.expectedLine, fixture.run) + ? output + : null; + }, + onTimeout: () => new Error(`expected completed synchronized row\n${fixture.run.output()}`), + }); + } + const raw = fixture.run.output(); + for (const attack of payload.attacks) { + expect(raw).not.toContain(attack); + } + expect(raw).not.toContain("\uFFFD"); +} + +async function assertHistoricalTerminalAttackSanitized( + fixture: StartedTuiPtyFixture, + payload: TerminalAttackPayload, + markers: string[], + expectedText: string, + timeoutMs: number, +) { + const observed = await fixture.run.waitForOutput(markers.at(-1) ?? "", timeoutMs); + const matchesHistoricalFrame = (raw: string) => + hasHistoricalSynchronizedFrameRow(raw, markers, expectedText, fixture.run); + const raw = matchesHistoricalFrame(observed) + ? observed + : await waitFor({ + timeoutMs, + read: () => { + const output = fixture.run.output(); + return matchesHistoricalFrame(output) ? output : null; + }, + onTimeout: () => + new Error(`expected historical completed synchronized row\n${fixture.run.output()}`), + }); + const visible = fixture.run.visibleOutput(); + expect(markers.every((marker) => visible.includes(marker))).toBe(true); + for (const attack of payload.attacks) { + expect(raw).not.toContain(attack); + } + expect(matchesHistoricalFrame(raw)).toBe(true); + expect(raw).not.toContain("\uFFFD"); +} + +async function assertTerminalAttackPrefixSanitized( + fixture: StartedTuiPtyFixture, + payload: TerminalAttackPayload, + timeoutMs: number, +) { + const markers = payload.markers.slice(0, 2); + await assertHistoricalTerminalAttackSanitized( + fixture, + payload, + markers, + markers.join(""), + timeoutMs, + ); +} + +function hasStatusFrame( + raw: string, + markers: string[], + status: RegExp, + dimensions: PtyTerminalDimensions, +) { + return latestFrameHasRow( + raw, + dimensions, + (row) => markers.every((marker) => row.includes(marker)) && status.test(row), + ); +} + +async function exerciseSelectorOutputSafety( + startFixture: StartTuiPtyFixture, + startupTimeoutMs: number, +) { + const modelValue = buildCompactTerminalAttackPayload("t08mv", "\x1b[777;888H"); + const modelName = buildCompactTerminalAttackPayload("t08mn", "\x1b]52;c;t08_model_clipboard\x07"); + const sessionTitle = buildCompactTerminalAttackPayload("t08st", "\x1b]0;t08_session_title\x07"); + const sessionPreview = buildCompactTerminalAttackPayload( + "t08sp", + "\u009d0;t08_session_preview\u009c", + ); + const sessionDisplay = buildCompactTerminalAttackPayload("t08sd", "\x1b[777\u0001m"); + const sessionKey = buildCompactTerminalAttackPayload("t08sk", "\u009b777;888h"); + const selectedModel = `fixture-provider/${modelValue.text}`; + const selectedSessionKey = `agent:main:${sessionKey.text}`; + const fixture = await startFixture({ + env: { + OPENCLAW_TUI_PTY_COLS: "240", + OPENCLAW_TUI_PTY_ROWS: "24", + OPENCLAW_TUI_PTY_MODEL: "fixture-provider/fixture-model", + OPENCLAW_TUI_PTY_PICKER_FIXTURE: "1", + OPENCLAW_TUI_PTY_PICKER_MODEL_VALUE: selectedModel, + OPENCLAW_TUI_PTY_PICKER_MODEL_NAME: modelName.text, + OPENCLAW_TUI_PTY_PICKER_SESSION_KEY: selectedSessionKey, + OPENCLAW_TUI_PTY_PICKER_SESSION_TITLE: sessionTitle.text, + OPENCLAW_TUI_PTY_PICKER_SESSION_PREVIEW: sessionPreview.text, + OPENCLAW_TUI_PTY_PICKER_SESSION_DISPLAY_NAME: sessionDisplay.text, + }, + }); + + try { + await fixture.run.waitForOutput("local ready", startupTimeoutMs); + await fixture.run.write("\u000c", { delay: false }); + await fixture.waitForLogEntry((entry) => entry.method === "listModels"); + await assertTerminalAttackPrefixSanitized(fixture, modelValue, 5_000); + await assertTerminalAttackPrefixSanitized(fixture, modelName, 5_000); + + await fixture.run.write("\x1b[B", { delay: false }); + await fixture.run.write("\r", { delay: false }); + const modelPatch = await fixture.waitForLogEntry( + (entry) => + entry.method === "patchSession" && objectFieldEquals(entry, "model", selectedModel), + ); + expect(modelPatch.payload).toMatchObject({ model: selectedModel }); + await fixture.run.waitForOutput( + formatTuiFooter({ + agentLabel: `main (${sessionDisplay.text})`, + sessionLabel: "main (Main)", + sessionInfo: { model: selectedModel, contextTokens: 128 }, + deliver: false, + }), + 5_000, + ); + await assertTerminalAttackSanitized(fixture, modelValue, 5_000); + + await fixture.run.write("\u0010", { delay: false }); + await fixture.waitForLogEntry( + (entry) => entry.method === "listSessions" && objectFieldEquals(entry, "purpose", "picker"), + ); + await assertTerminalAttackPrefixSanitized(fixture, sessionTitle, 5_000); + await assertTerminalAttackPrefixSanitized(fixture, sessionPreview, 5_000); + + await fixture.run.write("\x1b[B", { delay: false }); + await fixture.run.write("\r", { delay: false }); + const historyLoad = await fixture.waitForLogEntry( + (entry) => + entry.method === "loadHistory" && + objectFieldEquals(entry, "sessionKey", selectedSessionKey), + ); + expect(historyLoad.payload).toMatchObject({ sessionKey: selectedSessionKey }); + await assertTerminalAttackSanitized(fixture, sessionKey, 5_000); + const expectedAgentLabel = `main (${sessionDisplay.text})`; + const expectedSessionLabel = `${sessionKey.text} (${sessionDisplay.text})`; + await fixture.run.waitForOutput( + sanitizeRenderableLine( + `openclaw tui pty fixture - pty-fixture://local - agent ${expectedAgentLabel} - session ${sessionKey.text}`, + ), + 5_000, + ); + await fixture.run.waitForOutput( + formatTuiFooter({ + agentLabel: expectedAgentLabel, + sessionLabel: expectedSessionLabel, + sessionInfo: { model: selectedModel, contextTokens: 128 }, + deliver: false, + }), + 5_000, + ); + await assertTerminalAttackSanitized(fixture, sessionDisplay, 5_000); + expect(fixture.run.output()).not.toContain("\uFFFD"); + } finally { + await fixture.cleanup(); + } +} + +export async function exerciseNarrowTerminalRendering( + startFixture: StartTuiPtyFixture, + startupTimeoutMs: number, +) { + const url = + "https://example.test/tui/copy-safe/very-long-path/with-query?mode=narrow&value=alpha%20beta#proof"; + const message = + "terminal rendering proof Long output must wrap across several narrow terminal rows without " + + `losing text. Unicode stays intact: café 東京 👩🏽‍💻. Copy this URL exactly: ${url}`; + const fixture = await startFixture({ + env: { + OPENCLAW_TUI_PTY_COLS: "28", + OPENCLAW_TUI_PTY_ROWS: "18", + OPENCLAW_TUI_PTY_INITIAL_MESSAGE: message, + }, + }); + + try { + await fixture.run.waitForOutput("PTY_RESPONSE: terminal rendering proof", startupTimeoutMs); + await fixture.run.waitForOutput("café 東京 👩🏽‍💻", startupTimeoutMs); + const sent = await fixture.waitForLogEntry( + (entry) => entry.method === "sendChat" && objectFieldEquals(entry, "message", message), + ); + expect(sent.payload).toMatchObject({ message }); + const raw = fixture.run.output(); + expect(raw.split(`\x1b]8;;${url}\x07`).length - 1).toBeGreaterThan(1); + expect(raw).not.toContain("\uFFFD"); + } finally { + await fixture.cleanup(); + } +} + +async function exerciseGatewayOutputSafety( + startFixture: StartTuiPtyFixture, + startupTimeoutMs: number, +) { + const systemAttacks = [ + "\x1b[?7776h", + "\x1b[777;887H", + "\x1b]0;t08_system_title\x07", + "\x1b]52;c;t08_system_clipboard\x07", + "\u009b777;887H", + "\u009d0;t08_system_c1\u009c", + ]; + const idlePayload = buildCompactTerminalAttackPayload("T08I", "\x1b[?7775h"); + const fixture = await startFixture({ + env: { + OPENCLAW_TUI_PTY_COLS: "120", + OPENCLAW_TUI_PTY_ROWS: "18", + OPENCLAW_TUI_PTY_GATEWAY_STATUS: systemAttacks.join(""), + OPENCLAW_TUI_PTY_DISCONNECT_REASON: idlePayload.text, + }, + }); + + try { + await fixture.run.waitForOutput("local ready", startupTimeoutMs); + await fixture.run.write("/gateway-status\r", { delay: false }); + await fixture.waitForLogEntry((entry) => entry.method === "getGatewayStatus"); + await fixture.waitForLogEntry((entry) => entry.method === "disconnect"); + await fixture.run.waitForOutput("(no output)", startupTimeoutMs); + await assertTerminalAttackSanitized(fixture, idlePayload, startupTimeoutMs); + const raw = fixture.run.output(); + for (const attack of systemAttacks) { + expect(raw).not.toContain(attack); + } + expect(hasStatusFrame(fixture.run.output(), idlePayload.markers, /\| idle/u, fixture.run)).toBe( + true, + ); + + const helpOffset = fixture.run.visibleOutput().length; + await fixture.run.write("/help\r", { delay: false }); + await fixture.run.waitForOutput("Slash commands:", startupTimeoutMs); + await fixture.run.waitForOutput("/exit", startupTimeoutMs); + const helpOutput = fixture.run.visibleOutput().slice(helpOffset); + expect(helpOutput).toContain("/help"); + expect(helpOutput).toContain("/exit"); + } finally { + await fixture.cleanup(); + } +} + +async function exerciseMarkdownAndAutocompleteOutputSafety( + startFixture: StartTuiPtyFixture, + startupTimeoutMs: number, +) { + const inFlight = buildInlineTerminalAttackPayload("T08F", "\x1b]52;c;t08_inflight\x07"); + const command = buildCompactTerminalAttackPayload("T08C", "\u009d0;t08_command\u009c"); + const thinking = buildCompactTerminalAttackPayload("T08L", "\x1b[777;886H"); + const fixture = await startFixture({ + env: { + OPENCLAW_TUI_PTY_COLS: "140", + OPENCLAW_TUI_PTY_DYNAMIC_COMMAND_DESCRIPTION: command.text, + OPENCLAW_TUI_PTY_IN_FLIGHT_TEXT: `**${inFlight.text}** [copy-safe](https://example.test/t08-inflight)`, + OPENCLAW_TUI_PTY_ROWS: "22", + OPENCLAW_TUI_PTY_SAFE_THINKING_LABEL: "T08_SAFE_THINKING", + OPENCLAW_TUI_PTY_THINKING_LABEL: thinking.text, + }, + }); + + try { + await fixture.run.waitForOutput("local ready", startupTimeoutMs); + await fixture.waitForLogEntry((entry) => entry.method === "listCommands"); + const inFlightAssertion = assertHistoricalTerminalAttackSanitized( + fixture, + inFlight, + inFlight.markers, + inFlight.expectedLine, + 5_000, + ); + await fixture.run.write("\x14", { delay: false }); + await inFlightAssertion; + + await fixture.run.write("/t08d", { delay: false }); + const commandAssertion = assertHistoricalTerminalAttackSanitized( + fixture, + command, + command.markers, + command.expectedLine, + 5_000, + ); + await fixture.run.write("\x14", { delay: false }); + await commandAssertion; + + await fixture.run.write("\x1b", { delay: false }); + await sleep(50); + await fixture.run.write("\x15", { delay: false }); + await sleep(50); + await fixture.run.write("/think ", { delay: false }); + await fixture.run.waitForOutput("T08_SAFE_THINKING", 5_000); + const raw = fixture.run.output(); + expect(thinking.markers.some((marker) => raw.includes(marker))).toBe(false); + expect(thinking.attacks.some((attack) => raw.includes(attack))).toBe(false); + } finally { + await fixture.cleanup(); + } +} + +async function exerciseInteractiveOutputSafety( + startFixture: StartTuiPtyFixture, + startupTimeoutMs: number, +) { + const btwPayload = buildCompactTerminalAttackPayload("T08B", "\u009b776;889H"); + const rawToolPayload = buildCompactTerminalAttackPayload("T08T", "\x1b]0;t08_tool_title\x07"); + const toolPayload = { + ...rawToolPayload, + expectedLine: rawToolPayload.expectedLine.replace("café", "Café"), + }; + const fixture = await startFixture({ + env: { + OPENCLAW_TUI_PTY_BTW_QUESTION: btwPayload.text, + OPENCLAW_TUI_PTY_COLS: "120", + OPENCLAW_TUI_PTY_MODEL: "fixture-provider/fixture-model", + OPENCLAW_TUI_PTY_ROWS: "20", + OPENCLAW_TUI_PTY_TOOL_NAME: toolPayload.text, + OPENCLAW_TUI_PTY_VERBOSE_LEVEL: "on", + }, + }); + + try { + await fixture.run.waitForOutput("local ready", startupTimeoutMs); + await fixture.run.write("/btw picker focus proof\r", { delay: false }); + await fixture.waitForLogEntry((entry) => entry.method === "pickerSideResult"); + await assertTerminalAttackSanitized(fixture, btwPayload, startupTimeoutMs); + await fixture.run.write("\r", { delay: false }); + await sleep(25); + + await fixture.run.write("tool chronology proof\r", { delay: false }); + await fixture.waitForLogEntry((entry) => entry.method === "toolChronologyComplete"); + await assertTerminalAttackSanitized(fixture, toolPayload, startupTimeoutMs); + await fixture.run.waitForOutput("PTY_AFTER_TOOL", startupTimeoutMs); + } finally { + await fixture.cleanup(); + } +} + +export async function exerciseTerminalOutputSafety( + startFixture: StartTuiPtyFixture, + startupTimeoutMs: number, +) { + await Promise.all([ + exerciseGatewayOutputSafety(startFixture, startupTimeoutMs), + exerciseInteractiveOutputSafety(startFixture, startupTimeoutMs), + exerciseMarkdownAndAutocompleteOutputSafety(startFixture, startupTimeoutMs), + exerciseSelectorOutputSafety(startFixture, startupTimeoutMs), + ]); +} + +/** Proves fixture-local fragmentation preserves a Unicode prompt through the real TUI loop. */ +export async function exerciseFragmentedUnicodePrompt( + startFixture: (opts: { env?: NodeJS.ProcessEnv }) => Promise<{ + run: PtyRun; + waitForLogEntry: (predicate: (entry: FixtureLogEntry) => boolean) => Promise; + cleanup: () => Promise; + }>, + startupTimeoutMs: number, +) { + const fixture = await startFixture({ + env: { OPENCLAW_TUI_PTY_TYPE_CHUNK_SIZE: "1", OPENCLAW_TUI_PTY_TYPE_DELAY_MS: "1" }, + }); + const message = "hello 👋 from pty"; + + try { + await fixture.run.waitForOutput("local ready", startupTimeoutMs); + await fixture.run.write(`${message}\r`); + await fixture.run.waitForOutput(`PTY_RESPONSE: ${message}`); + await fixture.waitForLogEntry( + (entry) => entry.method === "sendChat" && objectFieldEquals(entry, "message", message), + ); + } finally { + await fixture.cleanup(); + } +} + +/** Approves a workspace skill using exact fragments that survive narrow-terminal wrapping. */ +export async function approveWorkspaceSkill( + fixture: { + run: PtyRun; + waitForLogEntry: (predicate: (entry: FixtureLogEntry) => boolean) => Promise; + }, + message: string, +) { + await fixture.run.write(`${message}\r`); + await fixture.run.waitForOutput("workspace skill approval: Apply workspace skill proposal"); + await fixture.run.waitForOutput("Plugin: workspace-skills"); + // A compact PTY wraps the request; exact fragments avoid matching across terminal redraws. + await fixture.run.waitForOutput("Apply a pending workspace skill proposal"); + await fixture.run.waitForOutput("into live workspace"); + await fixture.run.waitForOutput("skills."); + + await fixture.run.write("\x1b[A", { delay: false }); + await fixture.run.write("\r"); + await fixture.waitForLogEntry( + (entry) => + entry.method === "resolvePluginApproval" && + objectFieldEquals(entry, "decision", "allow-once"), + ); + await fixture.run.waitForOutput("PTY_SKILL_APPROVAL_RESOLVED: allow-once"); +} diff --git a/src/tui/tui-pty-harness-fixture-test-support.ts b/src/tui/tui-pty-harness-fixture-test-support.ts index 56f2459eee66..704bd250542f 100644 --- a/src/tui/tui-pty-harness-fixture-test-support.ts +++ b/src/tui/tui-pty-harness-fixture-test-support.ts @@ -1,12 +1,13 @@ // Keeps fake-terminal test-only logs and opaque-session fixtures independently bounded. -import { readFile, writeFile } from "node:fs/promises"; +import { writeFile } from "node:fs/promises"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { TUI_PTY_ASSISTANT_FIXTURE_SCRIPT } from "./tui-pty-assistant-fixture-test-support.js"; import { TUI_PTY_GAP_HISTORY_FIXTURE_SCRIPT } from "./tui-pty-gap-fixture-test-support.js"; import { TUI_PTY_RESET_FIXTURE } from "./tui-pty-reset-fixture-test-support.js"; import { TUI_PTY_SESSION_SUBSCRIPTION_FIXTURE_SCRIPT } from "./tui-pty-subscription-fixture-test-support.js"; -import { sleep, type PtyRun } from "./tui-pty-test-support.js"; + +export * from "./tui-pty-harness-assertion-test-support.js"; export async function writeTuiPtyFixtureScript(dir: string) { // Temp files sit outside the repo package scope; .mts preserves the ESM contract under tsx. @@ -40,7 +41,23 @@ export async function writeTuiPtyFixtureScript(dir: string) { let modeTargetTraceLevel: string | undefined; const launchThinkingLevel = process.env.OPENCLAW_TUI_PTY_LAUNCH_THINKING; const initialMessage = process.env.OPENCLAW_TUI_PTY_INITIAL_MESSAGE; + const inFlightRunText = process.env.OPENCLAW_TUI_PTY_IN_FLIGHT_TEXT; + const dynamicCommandDescription = process.env.OPENCLAW_TUI_PTY_DYNAMIC_COMMAND_DESCRIPTION; + const thinkingLabel = process.env.OPENCLAW_TUI_PTY_THINKING_LABEL; + const safeThinkingLabel = process.env.OPENCLAW_TUI_PTY_SAFE_THINKING_LABEL; + const thinkingLevels = [ + ...(thinkingLabel ? [{ id: "fixture-thinking", label: thinkingLabel }] : []), + ...(safeThinkingLabel ? [{ id: "fixture-thinking-safe", label: safeThinkingLabel }] : []), + ]; + const disconnectReason = process.env.OPENCLAW_TUI_PTY_DISCONNECT_REASON; + let disconnectPending = disconnectReason !== undefined; const enablePickerFixture = process.env.OPENCLAW_TUI_PTY_PICKER_FIXTURE === "1"; + const pickerModelValue = process.env.OPENCLAW_TUI_PTY_PICKER_MODEL_VALUE ?? "fixture-provider/fixture-model-2"; + const pickerModelName = process.env.OPENCLAW_TUI_PTY_PICKER_MODEL_NAME ?? "Fixture 2"; + const pickerSessionKey = process.env.OPENCLAW_TUI_PTY_PICKER_SESSION_KEY ?? "agent:main:picker-target"; + const pickerSessionTitle = process.env.OPENCLAW_TUI_PTY_PICKER_SESSION_TITLE; + const pickerSessionPreview = process.env.OPENCLAW_TUI_PTY_PICKER_SESSION_PREVIEW; + const pickerSessionDisplayName = process.env.OPENCLAW_TUI_PTY_PICKER_SESSION_DISPLAY_NAME ?? "Picker target"; const xaiLimitError = '403 {"code":"The caller does not have permission to execute the specified operation","error":"Your team team-redacted has either used all available credits or reached its monthly spending limit. To continue making API requests, please purchase more credits or raise your spending limit."}'; let currentModel = footerModel ?? "fixture-provider/fixture-model"; let currentThinkingLevel = footerThinkingLevel; @@ -85,7 +102,7 @@ export async function writeTuiPtyFixtureScript(dir: string) { const entryReasoningLevel = isModeSource ? "stream" : undefined; return { key, - displayName: "Main", + displayName: key === pickerSessionKey ? pickerSessionDisplayName : "Main", model: currentModel, modelProvider: "fixture-provider", contextTokens: 128, @@ -94,7 +111,7 @@ export async function writeTuiPtyFixtureScript(dir: string) { ...(entryVerboseLevel ? { verboseLevel: entryVerboseLevel } : {}), ...(entryTraceLevel ? { traceLevel: entryTraceLevel } : {}), ...(entryReasoningLevel ? { reasoningLevel: entryReasoningLevel } : {}), - thinkingLevels: [], + thinkingLevels, }; } @@ -105,8 +122,18 @@ export async function writeTuiPtyFixtureScript(dir: string) { connection = { url: "pty-fixture://local" }; onEvent?: TuiBackend["onEvent"]; onConnected?: TuiBackend["onConnected"]; + onDisconnected?: TuiBackend["onDisconnected"]; onGap?: TuiBackend["onGap"]; + emitDisconnect() { + if (!disconnectPending || disconnectReason === undefined) { + return; + } + disconnectPending = false; + record("disconnect"); + this.onDisconnected?.(disconnectReason); + } + start() { queueMicrotask(() => this.onConnected?.()); } @@ -145,7 +172,7 @@ export async function writeTuiPtyFixtureScript(dir: string) { const data = { phase: "start", toolCallId: "pty-chronology-tool", - name: "read_file", + name: process.env.OPENCLAW_TUI_PTY_TOOL_NAME ?? "read_file", args: { path: "chronology-proof.txt" }, }; this.onEvent?.({ event: "agent", payload: { runId, sessionKey: opts.sessionKey, stream: "tool", data } }); @@ -165,7 +192,7 @@ export async function writeTuiPtyFixtureScript(dir: string) { kind: "btw", runId, sessionKey: opts.sessionKey, - question: "picker focus proof", + question: process.env.OPENCLAW_TUI_PTY_BTW_QUESTION ?? "picker focus proof", text: "PTY_SIDE_OK", }, }); @@ -413,6 +440,9 @@ export async function writeTuiPtyFixtureScript(dir: string) { return { messages: [], fastMode, + ...(inFlightRunText + ? { inFlightRun: { runId: "run-restored-in-flight", text: inFlightRunText } } + : {}), ...(includeSessionInfo ? { thinkingLevel: footerThinkingLevel, @@ -427,7 +457,14 @@ export async function writeTuiPtyFixtureScript(dir: string) { purpose: opts?.includeDerivedTitles ? "picker" : "refresh", }); const sessions = enablePickerFixture - ? [sessionEntry("main"), { ...sessionEntry("agent:main:picker-target"), displayName: "Picker target" }] + ? [ + sessionEntry("main"), + { + ...sessionEntry(pickerSessionKey), + derivedTitle: pickerSessionTitle, + lastMessagePreview: pickerSessionPreview, + }, + ] : []; return { ts: Date.now(), @@ -438,7 +475,7 @@ export async function writeTuiPtyFixtureScript(dir: string) { model: currentModel, modelProvider: "fixture-provider", contextTokens: 128, - thinkingLevels: [], + thinkingLevels, }, }; } @@ -448,7 +485,7 @@ export async function writeTuiPtyFixtureScript(dir: string) { defaultId: "main", mainKey: "main", scope: "per-sender", - agents: [{ id: "main", name: "Main" }], + agents: [{ id: "main", name: enablePickerFixture ? pickerSessionDisplayName : "Main" }], }; } @@ -491,6 +528,7 @@ export async function writeTuiPtyFixtureScript(dir: string) { async getGatewayStatus() { record("getGatewayStatus"); + this.emitDisconnect(); return gatewayStatus; } @@ -498,10 +536,24 @@ export async function writeTuiPtyFixtureScript(dir: string) { record("listModels"); return [ { id: "fixture-provider/fixture-model", name: "Fixture", provider: "fixture-provider" }, - { id: "fixture-provider/fixture-model-2", name: "Fixture 2", provider: "fixture-provider" }, + { id: pickerModelValue, name: pickerModelName, provider: "fixture-provider" }, ]; } + async listCommands() { + record("listCommands"); + return dynamicCommandDescription + ? [{ + name: "t08dynamic", + textAliases: ["/t08dynamic"], + description: dynamicCommandDescription, + source: "plugin" as const, + scope: "text" as const, + acceptsArgs: false, + }] + : []; + } + async listPluginApprovals() { record("listPluginApprovals", { pending: Boolean(pendingPluginApproval) }); return pendingPluginApproval ? [pendingPluginApproval] : []; @@ -587,116 +639,6 @@ export async function writeTuiPtyFixtureScript(dir: string) { return scriptPath; } -export type FixtureLogEntry = { - method: string; - payload?: unknown; -}; - -export const COMPACT_TERMINAL_SIZES = [ - [64, 18], - [68, 18], - [72, 20], - [80, 20], -] as const; - -export async function readFixtureLog(logPath: string): Promise { - try { - const text = await readFile(logPath, "utf8"); - return text - .split("\n") - .filter(Boolean) - .map((line) => JSON.parse(line) as FixtureLogEntry); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - return []; - } - throw error; - } -} - -export async function waitForFixtureLogEntry( - logPath: string, - predicate: (entry: FixtureLogEntry) => boolean, - timeoutMs: number, - readPtyOutput?: () => string, -) { - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - const entries = await readFixtureLog(logPath); - const match = entries.find(predicate); - if (match) { - return match; - } - await sleep(25); - } - const entries = await readFixtureLog(logPath); - // A swallowed command leaves no RPC; its visible rejection survives only in the terminal. - const ptyOutput = readPtyOutput?.() ?? ""; - throw new Error( - `timed out waiting for fixture log entry\n${JSON.stringify(entries, null, 2)}\n${ptyOutput}`, - ); -} - -export function objectFieldEquals(entry: FixtureLogEntry, field: string, value: unknown) { - if (typeof entry.payload !== "object" || entry.payload === null) { - return false; - } - const payload = entry.payload as Record; - return Object.hasOwn(payload, field) && payload[field] === value; -} - -/** Proves fixture-local fragmentation preserves a Unicode prompt through the real TUI loop. */ -export async function exerciseFragmentedUnicodePrompt( - startFixture: (opts: { env?: NodeJS.ProcessEnv }) => Promise<{ - run: PtyRun; - waitForLogEntry: (predicate: (entry: FixtureLogEntry) => boolean) => Promise; - cleanup: () => Promise; - }>, - startupTimeoutMs: number, -) { - const fixture = await startFixture({ - env: { OPENCLAW_TUI_PTY_TYPE_CHUNK_SIZE: "1", OPENCLAW_TUI_PTY_TYPE_DELAY_MS: "1" }, - }); - const message = "hello 👋 from pty"; - - try { - await fixture.run.waitForOutput("local ready", startupTimeoutMs); - await fixture.run.write(`${message}\r`); - await fixture.run.waitForOutput(`PTY_RESPONSE: ${message}`); - await fixture.waitForLogEntry( - (entry) => entry.method === "sendChat" && objectFieldEquals(entry, "message", message), - ); - } finally { - await fixture.cleanup(); - } -} - -/** Approves a workspace skill using exact fragments that survive narrow-terminal wrapping. */ -export async function approveWorkspaceSkill( - fixture: { - run: PtyRun; - waitForLogEntry: (predicate: (entry: FixtureLogEntry) => boolean) => Promise; - }, - message: string, -) { - await fixture.run.write(`${message}\r`); - await fixture.run.waitForOutput("workspace skill approval: Apply workspace skill proposal"); - await fixture.run.waitForOutput("Plugin: workspace-skills"); - // A compact PTY wraps the request; exact fragments avoid matching across terminal redraws. - await fixture.run.waitForOutput("Apply a pending workspace skill proposal"); - await fixture.run.waitForOutput("into live workspace"); - await fixture.run.waitForOutput("skills."); - - await fixture.run.write("\x1b[A", { delay: false }); - await fixture.run.write("\r"); - await fixture.waitForLogEntry( - (entry) => - entry.method === "resolvePluginApproval" && - objectFieldEquals(entry, "decision", "allow-once"), - ); - await fixture.run.waitForOutput("PTY_SKILL_APPROVAL_RESOLVED: allow-once"); -} - function buildOpaqueSessionIsolationFixture(): string { return ` if (opts.message.startsWith("opaque session isolation proof: ")) { diff --git a/src/tui/tui-pty-harness.e2e.test.ts b/src/tui/tui-pty-harness.e2e.test.ts index fd8a434a6293..0cded6e86f58 100644 --- a/src/tui/tui-pty-harness.e2e.test.ts +++ b/src/tui/tui-pty-harness.e2e.test.ts @@ -7,6 +7,8 @@ import { approveWorkspaceSkill, COMPACT_TERMINAL_SIZES, exerciseFragmentedUnicodePrompt, + exerciseNarrowTerminalRendering, + exerciseTerminalOutputSafety, objectFieldEquals, readFixtureLog, waitForFixtureLogEntry, @@ -685,6 +687,14 @@ describe.sequential("TUI PTY harness", () => { TEST_TIMEOUT_MS, ); + // Keep these producer-matched cases data-driven because this harness is at its line budget. + // prettier-ignore + const terminalSafetyCases = [ + ["renders long Unicode output and copy-safe URLs in narrow real PTY frames", () => exerciseNarrowTerminalRendering(startTuiFixture, STARTUP_TIMEOUT_MS)], + ["sanitizes ANSI OSC and C1 payloads across real PTY display boundaries", () => exerciseTerminalOutputSafety(startTuiFixture, STARTUP_TIMEOUT_MS)], + ] as const; + it.each(terminalSafetyCases)("%s", async (_name, runCase) => runCase(), STARTUP_TEST_TIMEOUT_MS); + it( "preserves xAI account limit errors in terminal output", async () => { @@ -895,16 +905,8 @@ describe.sequential("TUI PTY harness", () => { "renders slash command help", async () => { await fixture.run.write("/help\r", { delay: false }); - await fixture.run.waitForOutput("Slash commands:"); - await fixture.run.waitForOutput("/help"); - await fixture.run.waitForOutput("/verbose "); - await fixture.run.waitForOutput("/reasoning "); - await fixture.run.waitForOutput("/goal"); - await fixture.run.waitForOutput("/goal start "); - await fixture.run.waitForOutput("/btw "); - await fixture.run.waitForOutput("/queue"); - await fixture.run.waitForOutput("/stop"); - await fixture.run.waitForOutput("/exit"); + // prettier-ignore + for (const text of ["Slash commands:", "/help", "/verbose ", "/reasoning ", "/goal", "/goal start ", "/btw ", "/queue", "/stop", "/exit"]) { await fixture.run.waitForOutput(text); } }, TEST_TIMEOUT_MS, ); diff --git a/src/tui/tui-pty-local-test-support.test.ts b/src/tui/tui-pty-local-test-support.test.ts index bcbd3c652753..ac22bcbf0d16 100644 --- a/src/tui/tui-pty-local-test-support.test.ts +++ b/src/tui/tui-pty-local-test-support.test.ts @@ -28,7 +28,9 @@ describe("local TUI PTY fixture support", () => { let output = ""; let acceptanceTimer: ReturnType | undefined; const run = { + cols: 100, output: () => output, + rows: 30, visibleOutput: () => output.replace(/\s+/gu, " "), write: async (data: string) => { writes.push(data); diff --git a/src/tui/tui-pty-test-support.test.ts b/src/tui/tui-pty-test-support.test.ts index 367e83be808f..7c029ccdc472 100644 --- a/src/tui/tui-pty-test-support.test.ts +++ b/src/tui/tui-pty-test-support.test.ts @@ -60,7 +60,7 @@ describe("TUI PTY test support", () => { it("applies fixture-specific terminal dimensions", () => { nodePtyMocks.spawn.mockReturnValue(createMockPty()); - startPty("node", [], { + const run = startPty("node", [], { cwd: process.cwd(), env: { OPENCLAW_TUI_PTY_COLS: "72", @@ -78,12 +78,13 @@ describe("TUI PTY test support", () => { rows: 20, }), ); + expect(run).toMatchObject({ cols: 72, rows: 20 }); }); it("falls back when fixture-specific terminal dimensions are invalid", () => { nodePtyMocks.spawn.mockReturnValue(createMockPty()); - startPty("node", [], { + const run = startPty("node", [], { cwd: process.cwd(), env: { OPENCLAW_TUI_PTY_COLS: "0", @@ -101,6 +102,7 @@ describe("TUI PTY test support", () => { rows: 30, }), ); + expect(run).toMatchObject({ cols: 100, rows: 30 }); }); it.each([ diff --git a/src/tui/tui-pty-test-support.ts b/src/tui/tui-pty-test-support.ts index bebdb9acbac4..e15739d9bf34 100644 --- a/src/tui/tui-pty-test-support.ts +++ b/src/tui/tui-pty-test-support.ts @@ -3,6 +3,7 @@ import { appendFileSync } from "node:fs"; import * as nodePty from "@lydell/node-pty"; import type { IPty } from "@lydell/node-pty"; import { AnsiSequenceStripper } from "../../packages/terminal-core/src/ansi-sequences.js"; +import * as ansi from "../../packages/terminal-core/src/ansi.js"; import { toErrorObject } from "../infra/errors.js"; import { signalProcessTree } from "../process/kill-tree.js"; @@ -11,7 +12,9 @@ type PtyExitEvent = Parameters[0]>[0]; /** Handle returned by PTY tests for input, output waits, and cleanup. */ export type PtyRun = { + cols: number; output: () => string; + rows: number; visibleOutput: () => string; write: (data: string, opts?: { delay?: boolean }) => Promise; waitForOutput: (needle: string, timeoutMs?: number) => Promise; @@ -21,6 +24,174 @@ export type PtyRun = { dispose: () => Promise; }; +export type PtyTerminalDimensions = Pick; +type PtyTestCell = { authenticated: boolean; text: string }; + +const MAX_TEST_TERMINAL_DIMENSION = 1_000; + +/** Minimal bounded terminal state used only to authenticate PTY test evidence. */ +export class PtyTestScreen { + readonly cells: PtyTestCell[][]; + readonly cols: number; + readonly rows: number; + col = 0; + row = 0; + private wrapPending = false; + + constructor(dimensions: PtyTerminalDimensions) { + const { cols, rows } = dimensions; + const valid = [cols, rows].every( + (value) => Number.isSafeInteger(value) && value > 0 && value <= MAX_TEST_TERMINAL_DIMENSION, + ); + if (!valid) { + throw new Error(`unsupported TUI PTY dimensions: ${cols}x${rows}`); + } + this.cols = cols; + this.rows = rows; + this.cells = Array.from({ length: rows }, () => this.blankRow()); + } + + write(text: string, authenticated: boolean) { + for (const part of text.split(/([\b\r\n\t])/u)) { + if (part === "\r") { + this.col = 0; + this.wrapPending = false; + } else if (part === "\n") { + this.lineFeed(authenticated, false); + } else if (part === "\t") { + this.col = Math.min(this.cols - 1, Math.floor(this.col / 8 + 1) * 8); + this.wrapPending = false; + } else if (part === "\b") { + this.col = Math.max(0, this.col - 1); + this.wrapPending = false; + } else { + this.writeGraphemes(part, authenticated); + } + } + } + + applyCsi(value: string, authenticated: boolean) { + const final = value.at(-1) ?? ""; + const param = value.slice(2, -1); + const count = Number(param || "1"); + if (final === "A") { + this.row = Math.max(0, this.row - count); + } else if (final === "B") { + this.row = Math.min(this.rows - 1, this.row + count); + } else if (final === "G") { + this.col = Math.min(this.cols - 1, count - 1); + } else if (value === "\x1b[H") { + this.row = 0; + this.col = 0; + } else if (final === "J") { + const mode = Number(param || "0"); + if (mode === 0) { + this.clearFrom(this.row, this.col, authenticated); + } else if (mode === 2) { + this.clearFrom(0, 0, authenticated); + } + } else if (final === "K") { + const mode = Number(param || "0"); + if (mode === 0) { + this.clearRow(this.row, this.col, authenticated); + } else if (mode === 2) { + this.clearRow(this.row, 0, authenticated); + } + } + if (/[ABGHK]$/u.test(value) || (final === "J" && param !== "3")) { + this.wrapPending = false; + } + } + + private blankRow(authenticated = false): PtyTestCell[] { + return Array.from({ length: this.cols }, () => ({ authenticated, text: " " })); + } + + private rowCells(row = this.row) { + const cells = this.cells[row]; + if (!cells) { + throw new Error(`terminal row outside viewport: ${row}`); + } + return cells; + } + + private clearCell(cells: PtyTestCell[], col: number, authenticated: boolean) { + let lead = col; + while (lead > 0 && cells[lead]?.text === "") { + lead -= 1; + } + const width = Math.max(1, ansi.visibleWidth(cells[lead]?.text ?? "")); + for (let index = lead; index < Math.min(cells.length, lead + width); index += 1) { + cells[index] = { authenticated, text: " " }; + } + } + + private clearRow(row: number, col: number, authenticated: boolean) { + const cells = this.rowCells(row); + let start = Math.min(col, this.cols - 1); + while (start > 0 && cells[start]?.text === "") { + start -= 1; + } + for (let index = start; index < this.cols; index += 1) { + cells[index] = { authenticated, text: " " }; + } + } + + private clearFrom(row: number, col: number, authenticated: boolean) { + for (let index = row; index < this.rows; index += 1) { + this.clearRow(index, index === row ? col : 0, authenticated); + } + } + + private lineFeed(authenticated: boolean, carriageReturn: boolean) { + if (this.row === this.rows - 1) { + this.cells.shift(); + this.cells.push(this.blankRow(authenticated)); + } else { + this.row += 1; + } + if (carriageReturn) { + this.col = 0; + } + this.wrapPending = false; + } + + private writeGraphemes(text: string, authenticated: boolean) { + for (const grapheme of ansi.splitGraphemes(text)) { + if (ansi.sanitizeForLog(grapheme) !== grapheme) { + throw new Error("unsupported terminal control in TUI PTY evidence"); + } + if (/[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/u.test(grapheme)) { + continue; + } + const width = ansi.visibleWidth(grapheme); + if (width === 0) { + continue; + } + if (width > this.cols) { + throw new Error("grapheme exceeds TUI PTY width"); + } + if (this.wrapPending || this.col + width > this.cols) { + this.lineFeed(authenticated, true); + } + const cells = this.rowCells(); + for (let col = this.col; col < this.col + width; col += 1) { + this.clearCell(cells, col, authenticated); + } + cells[this.col] = { authenticated, text: grapheme }; + for (let col = this.col + 1; col < this.col + width; col += 1) { + cells[col] = { authenticated, text: "" }; + } + if (this.col + width === this.cols) { + this.col = this.cols - 1; + this.wrapPending = true; + } else { + this.col += width; + } + } + } +} + const PTY_EXIT_SETTLE_MS = 25; /** Polls until a reader returns a value or the timeout expires. */ @@ -126,10 +297,12 @@ export function startPty( ptyEnv[key] = value; } } + const cols = readPtyDimensionEnv("OPENCLAW_TUI_PTY_COLS", 100, ptyEnv); + const rows = readPtyDimensionEnv("OPENCLAW_TUI_PTY_ROWS", 30, ptyEnv); const pty = nodePty.spawn(command, args, { name: "xterm-256color", - cols: readPtyDimensionEnv("OPENCLAW_TUI_PTY_COLS", 100, ptyEnv), - rows: readPtyDimensionEnv("OPENCLAW_TUI_PTY_ROWS", 30, ptyEnv), + cols, + rows, cwd: opts.cwd, env: ptyEnv, }); @@ -155,6 +328,26 @@ export function startPty( onTimeout: () => new Error(`timed out waiting for PTY exit\n${output}`), }); + const waitForVisibleOutput = async (needle: string, timeoutMs: number) => { + const normalizedNeedle = needle.replace(/\s+/gu, " "); + return await waitFor({ + timeoutMs, + read: () => { + const matchIndex = visibleOutput.indexOf(normalizedNeedle); + if (matchIndex >= 0) { + return output; + } + if (exitEvent) { + throw new Error( + `PTY exited before ${JSON.stringify(needle)}\nexit=${JSON.stringify(exitEvent)}\n${output}`, + ); + } + return null; + }, + onTimeout: () => new Error(`timed out waiting for ${JSON.stringify(needle)}\n${output}`), + }); + }; + let forceKillPromise: Promise | undefined; let disposePromise: Promise | undefined; let subscriptionsDisposed = false; @@ -181,25 +374,13 @@ export function startPty( }; const run: PtyRun = { + cols, output: () => output, + rows, visibleOutput: () => visibleOutput, write: async (data, writeOpts) => await writePtyInput(pty, data, ptyEnv, writeOpts), waitForOutput: async (needle, timeoutMs = opts.outputTimeoutMs) => - await waitFor({ - timeoutMs, - read: () => { - if (visibleOutput.includes(needle.replace(/\s+/gu, " "))) { - return output; - } - if (exitEvent) { - throw new Error( - `PTY exited before ${JSON.stringify(needle)}\nexit=${JSON.stringify(exitEvent)}\n${output}`, - ); - } - return null; - }, - onTimeout: () => new Error(`timed out waiting for ${JSON.stringify(needle)}\n${output}`), - }), + await waitForVisibleOutput(needle, timeoutMs), waitForExit, forceKill: () => { forceKillPromise ??= (async () => { diff --git a/src/tui/tui.ts b/src/tui/tui.ts index 44e7f4808d29..7f19f718ab01 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -40,10 +40,15 @@ import { ChatLog } from "./components/chat-log.js"; import { CustomEditor } from "./components/custom-editor.js"; import { resolveLocalRunShutdownGraceMs } from "./local-run-shutdown.js"; import { editorTheme, theme } from "./theme/theme.js"; +import { sanitizeAutocompleteProvider } from "./tui-autocomplete.js"; import type { TuiBackend } from "./tui-backend.js"; import { createCommandHandlers } from "./tui-command-handlers.js"; import { createEventHandlers } from "./tui-event-handlers.js"; -import { formatTuiFooter, formatTuiErrorMessage } from "./tui-formatters.js"; +import { + formatTuiErrorMessage, + formatTuiFooter, + sanitizeRenderableLine, +} from "./tui-formatters.js"; import { buildTuiLastSessionScopeKey, readTuiLastSessionKey, @@ -787,7 +792,9 @@ export async function runTui(opts: RunTuiOptions): Promise { editor.shouldSubmitAutocomplete = (text) => shouldSubmitExactArgumentCompletion(text, slashCommands); editor.setAutocompleteProvider( - new CombinedAutocompleteProvider(slashCommands, resolveUsableCwd()), + sanitizeAutocompleteProvider( + new CombinedAutocompleteProvider(slashCommands, resolveUsableCwd()), + ), ); }; @@ -963,11 +970,8 @@ export async function runTui(opts: RunTuiOptions): Promise { const sessionLabel = formatSessionKey(currentSessionKey); const agentLabel = formatAgentLabel(state.currentAgentId); const title = opts.title ?? "openclaw tui"; - header.setText( - theme.header( - `${title} - ${client.connection.url} - agent ${agentLabel} - session ${sessionLabel}`, - ), - ); + const text = `${title} - ${client.connection.url} - agent ${agentLabel} - session ${sessionLabel}`; + header.setText(theme.header(sanitizeRenderableLine(text))); }; let statusText: Text | null = null; @@ -1135,7 +1139,7 @@ export async function runTui(opts: RunTuiOptions): Promise { }; const setConnectionStatus = (text: string, ttlMs?: number) => { - state.connectionStatus = text; + state.connectionStatus = sanitizeRenderableLine(text); renderStatus(); if (state.statusTimeout) { stopStatusTimeout(); diff --git a/test/e2e/qa-lab/tui/tui-pty-evidence-producer.test.ts b/test/e2e/qa-lab/tui/tui-pty-evidence-producer.test.ts index d6ed02243d5b..fce2c1ebaab0 100644 --- a/test/e2e/qa-lab/tui/tui-pty-evidence-producer.test.ts +++ b/test/e2e/qa-lab/tui/tui-pty-evidence-producer.test.ts @@ -17,6 +17,7 @@ import { } from "./tui-pty-evidence-producer.js"; const SOURCE_PATH = "test/e2e/qa-lab/tui/tui-pty-evidence-producer.ts"; +const ASSERTION_SUPPORT_FILE = "src/tui/tui-pty-harness-assertion-test-support.test.ts"; const HARNESS_FILE = "src/tui/tui-pty-harness.e2e.test.ts"; const LOCAL_FILE = "src/tui/tui-pty-local.e2e.test.ts"; const RESET_FILE = "src/tui/tui-reset-transition-pty.e2e.test.ts"; @@ -78,7 +79,7 @@ function makeCase(overrides: Partial = {}): TuiPtyCase { async function makeTempRepo() { const repoRoot = tempDirs.make("openclaw-tui-pty-producer-"); - for (const testFile of [HARNESS_FILE, LOCAL_FILE, RESET_FILE]) { + for (const testFile of [ASSERTION_SUPPORT_FILE, HARNESS_FILE, LOCAL_FILE, RESET_FILE]) { const absolutePath = path.join(repoRoot, testFile); await fs.mkdir(path.dirname(absolutePath), { recursive: true }); await fs.writeFile(absolutePath, "// fixture\n", "utf8"); @@ -252,6 +253,14 @@ describe("TUI PTY evidence producer", () => { expect(fake.env.OPENCLAW_TUI_PTY_INCLUDE_LOCAL).toBeUndefined(); expect(fake.env.OPENCLAW_TUI_PTY_USE_BUILT_CLI).toBeUndefined(); + const oracle = buildTuiPtyVitestCommand({ + cases: [makeCase({ testFile: ASSERTION_SUPPORT_FILE })], + cliMode: "source", + repoRoot: "/repo", + reportPath: "/artifacts/report.json", + }); + expect(oracle.args).toContain(ASSERTION_SUPPORT_FILE); + const local = buildTuiPtyVitestCommand({ cases: [makeCase({ testFile: LOCAL_FILE })], cliMode: "built", diff --git a/test/e2e/qa-lab/tui/tui-pty-evidence-producer.ts b/test/e2e/qa-lab/tui/tui-pty-evidence-producer.ts index 8c8ad7233ede..2b056f66b33f 100644 --- a/test/e2e/qa-lab/tui/tui-pty-evidence-producer.ts +++ b/test/e2e/qa-lab/tui/tui-pty-evidence-producer.ts @@ -24,6 +24,7 @@ const BUILT_CLI_REQUIREMENT = "cliMode=built requires readable openclaw.mjs and at least one readable dist/entry.js or dist/entry.mjs"; export const TUI_PTY_TEST_FILE_ALLOWLIST = [ + "src/tui/tui-pty-harness-assertion-test-support.test.ts", "src/tui/tui-pty-harness.e2e.test.ts", LOCAL_PTY_TEST_FILE, "src/tui/tui-reset-transition-pty.e2e.test.ts", diff --git a/test/vitest/vitest.tui-pty.config.ts b/test/vitest/vitest.tui-pty.config.ts index 348bb73e12f0..d18cdf19ab46 100644 --- a/test/vitest/vitest.tui-pty.config.ts +++ b/test/vitest/vitest.tui-pty.config.ts @@ -4,9 +4,11 @@ import { loadPatternListFromEnv, narrowIncludePatternsForCli } from "./vitest.pa import { resolveRepoRootPath, sharedVitestConfig } from "./vitest.shared.config.ts"; const targetableIncludes = [ + "src/tui/tui-pty-harness-assertion-test-support.test.ts", "src/tui/tui-pty-harness.e2e.test.ts", "src/tui/tui-pty-local.e2e.test.ts", "src/tui/tui-reset-transition-pty.e2e.test.ts", + "tui/tui-pty-harness-assertion-test-support.test.ts", "tui/tui-pty-harness.e2e.test.ts", "tui/tui-pty-local.e2e.test.ts", "tui/tui-reset-transition-pty.e2e.test.ts",