mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
@@ -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)");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
@@ -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: ")) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user