fix(tui): block terminal controls in system messages (#118998)

* fix(tui): sanitize rendered system messages

* test(tui): keep PTY safety proof within lint budget

* test(tui): clarify terminal safety PTY proof

* test(tui): de-churn terminal safety harness

* fix(tui): sanitize connection status output

* test(tui): split PTY harness support

* fix(tui): sanitize selector and footer output

* fix(tui): harden terminal display boundaries

* fix(tui): harden terminal display boundaries

* fix(tui): harden terminal output boundaries

* test(tui): repair terminal safety proof

* test(tui): harden terminal safety oracle

* test(tui): type OSC terminator candidates

* fix(tui): authenticate current terminal rendering

* fix(tui): harden terminal output safety evidence

* test(tui): keep PTY cell type internal

* test(tui): reject literal tabs in PTY oracle

* test(tui): reject literal backspace in PTY oracle
This commit is contained in:
Vincent Koc
2026-08-04 16:35:41 +08:00
committed by GitHub
parent 1e3880352e
commit 4bdf9ca789
30 changed files with 2041 additions and 226 deletions
@@ -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$
@@ -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)");
},
);
});
+12 -3
View File
@@ -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));
}
+47 -9
View File
@@ -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);
});
});
+7 -4
View File
@@ -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;
}
@@ -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);
});
});
+19 -3
View File
@@ -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 {
+97 -1
View File
@@ -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);
});
});
+15 -5
View File
@@ -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 {
@@ -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<string, RegExp> }).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;
+6 -2
View File
@@ -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) {
+63
View File
@@ -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);
});
});
+13 -9
View File
@@ -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);
+13
View File
@@ -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"];
+11 -5
View File
@@ -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;
}
+109
View File
@@ -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",
});
});
});
+50
View File
@@ -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,
};
}
+101
View File
@@ -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]");
});
});
+59 -21
View File
@@ -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. */
@@ -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[<u\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();
});
});
@@ -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<FixtureLogEntry[]> {
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<string, unknown>;
return Object.hasOwn(payload, field) && payload[field] === value;
}
type StartTuiPtyFixture = Parameters<typeof exerciseFragmentedUnicodePrompt>[0];
type StartedTuiPtyFixture = Awaited<ReturnType<StartTuiPtyFixture>>;
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|<u|>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<FixtureLogEntry>;
cleanup: () => Promise<void>;
}>,
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<FixtureLogEntry>;
},
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");
}
+62 -120
View File
@@ -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<FixtureLogEntry[]> {
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<string, unknown>;
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<FixtureLogEntry>;
cleanup: () => Promise<void>;
}>,
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<FixtureLogEntry>;
},
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: ")) {
+12 -10
View File
@@ -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 <on|off|full>");
await fixture.run.waitForOutput("/reasoning <on|off|stream>");
await fixture.run.waitForOutput("/goal");
await fixture.run.waitForOutput("/goal start <objective>");
await fixture.run.waitForOutput("/btw <side question>");
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 <on|off|full>", "/reasoning <on|off|stream>", "/goal", "/goal start <objective>", "/btw <side question>", "/queue", "/stop", "/exit"]) { await fixture.run.waitForOutput(text); }
},
TEST_TIMEOUT_MS,
);
@@ -28,7 +28,9 @@ describe("local TUI PTY fixture support", () => {
let output = "";
let acceptanceTimer: ReturnType<typeof setTimeout> | undefined;
const run = {
cols: 100,
output: () => output,
rows: 30,
visibleOutput: () => output.replace(/\s+/gu, " "),
write: async (data: string) => {
writes.push(data);
+4 -2
View File
@@ -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([
+198 -17
View File
@@ -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<Parameters<IPty["onExit"]>[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<void>;
waitForOutput: (needle: string, timeoutMs?: number) => Promise<string>;
@@ -21,6 +24,174 @@ export type PtyRun = {
dispose: () => Promise<void>;
};
export type PtyTerminalDimensions = Pick<PtyRun, "cols" | "rows">;
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<void> | undefined;
let disposePromise: Promise<void> | 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 () => {
+12 -8
View File
@@ -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<TuiResult> {
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<TuiResult> {
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<TuiResult> {
};
const setConnectionStatus = (text: string, ttlMs?: number) => {
state.connectionStatus = text;
state.connectionStatus = sanitizeRenderableLine(text);
renderStatus();
if (state.statusTimeout) {
stopStatusTimeout();
@@ -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> = {}): 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",
@@ -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",
+2
View File
@@ -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",