diff --git a/src/openclaw/__tests__/terminal-probe-sanitization.test.ts b/src/openclaw/__tests__/terminal-probe-sanitization.test.ts new file mode 100644 index 000000000..75ca0df29 --- /dev/null +++ b/src/openclaw/__tests__/terminal-probe-sanitization.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, test } from "bun:test" +import { stripTerminalProbes } from "../tmux" +import { sanitizeReplyInput } from "../reply-listener" + +describe("terminal probe text sanitization", () => { + describe("stripTerminalProbes", () => { + test("should strip CSI device attributes reply (DA)", () => { + // given — DA reply: ESC[?64;1;2;6;22c + const text = "normal text\x1b[?64;1;2;6;22c more text" + + // when + const result = stripTerminalProbes(text) + + // then + expect(result).toBe("normal text more text") + }) + + test("should strip CSI cursor position reply (CPR)", () => { + // given — CPR reply: ESC[24;80R + const text = "hello\x1b[24;80R world" + + // when + const result = stripTerminalProbes(text) + + // then + expect(result).toBe("hello world") + }) + + test("should strip OSC color query reply", () => { + // given — OSC 4 color reply with BEL terminator + const text = "before\x1b]4;0;rgb:0000/0000/0000\x07after" + + // when + const result = stripTerminalProbes(text) + + // then + expect(result).toBe("beforeafter") + }) + + test("should strip OSC color query reply with ST terminator", () => { + // given — OSC 10 foreground color reply with ST (ESC\) + const text = "before\x1b]10;rgb:ffff/ffff/ffff\x1b\\after" + + // when + const result = stripTerminalProbes(text) + + // then + expect(result).toBe("beforeafter") + }) + + test("should strip SGR color/formatting sequences", () => { + // given — typical colored output + const text = "\x1b[32m✓\x1b[0m test passed" + + // when + const result = stripTerminalProbes(text) + + // then + expect(result).toBe("✓ test passed") + }) + + test("should strip multiple mixed probe sequences", () => { + // given — multiple probe replies in one capture + const text = "\x1b[?64;1;2c\x1b]4;0;rgb:0000/0000/0000\x07\x1b[24;80Rnormal output" + + // when + const result = stripTerminalProbes(text) + + // then + expect(result).toBe("normal output") + }) + + test("should strip control characters but preserve newlines and tabs", () => { + // given + const text = "line1\nline2\t\x03indented\x08backspace" + + // when + const result = stripTerminalProbes(text) + + // then + expect(result).toBe("line1\nline2\tindentedbackspace") + }) + + test("should pass through clean text unchanged", () => { + // given + const text = "opencode\nAsk anything...\nRun /help" + + // when + const result = stripTerminalProbes(text) + + // then + expect(result).toBe("opencode\nAsk anything...\nRun /help") + }) + + test("should handle the exact bug report pattern (hex-like probe residue)", () => { + // given — the pattern from issue #2887: + // "414/21212a2/6969717/98989f9f/b3b3f2f2/f2f2414/2121" + // This is what remains after partial escape sequence processing. + // The actual raw bytes would include ESC sequences that produce this residue. + // After stripping ESC sequences, only the residue digits/letters remain. + // The key fix is preventing the ESC sequences from being captured in the first place. + const rawWithEsc = "\x1b[?64;1;2c414/21212a2\x1b[?6n" + + // when + const result = stripTerminalProbes(rawWithEsc) + + // then — ESC sequences stripped, residue text remains but is harmless + expect(result).toBe("414/21212a2") + }) + }) + + describe("sanitizeReplyInput handles escape sequences", () => { + test("should strip ANSI escape sequences from reply input", () => { + // given + const text = "reply text\x1b[?64;1;2c with probe" + + // when + const result = sanitizeReplyInput(text) + + // then — escape sequence stripped, newlines collapsed to spaces + expect(result).toBe("reply text with probe") + }) + + test("should strip OSC sequences from reply input", () => { + // given + const text = "before\x1b]4;0;rgb:0000/0000/0000\x07after" + + // when + const result = sanitizeReplyInput(text) + + // then + expect(result).toBe("beforeafter") + }) + }) +}) diff --git a/src/openclaw/reply-listener.ts b/src/openclaw/reply-listener.ts index f6c8e015b..1ae1d491a 100644 --- a/src/openclaw/reply-listener.ts +++ b/src/openclaw/reply-listener.ts @@ -225,6 +225,15 @@ export async function isDaemonRunning(): Promise { // Input Sanitization export function sanitizeReplyInput(text: string): string { return text + // Strip ANSI/CSI escape sequences (colors, cursor, device attributes, etc.) + .replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, "") + // Strip OSC sequences (terminal color queries, title sets, etc.) + .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "") + // Strip DCS sequences + .replace(/\x1bP[^\x1b]*\x1b\\/g, "") + // Strip remaining bare ESC sequences + .replace(/\x1b[^\[\]P]/g, "") + // Strip basic control characters .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "") .replace(/[\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, "") .replace(/\r?\n/g, " ") diff --git a/src/openclaw/tmux.ts b/src/openclaw/tmux.ts index 6b575e662..c04a9fbe5 100644 --- a/src/openclaw/tmux.ts +++ b/src/openclaw/tmux.ts @@ -25,6 +25,25 @@ export async function getTmuxSessionName(): Promise { } } +/** + * Strip terminal probe/control sequences from captured pane text. + * Tmux capture-pane can include ANSI CSI, OSC, and DCS sequences + * from terminal capability probes during pane startup. + */ +export function stripTerminalProbes(text: string): string { + return text + // CSI sequences: ESC [ ... (device attributes, cursor position, etc.) + .replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, "") + // OSC sequences: ESC ] ... (BEL or ST) (color queries, window title, etc.) + .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "") + // DCS sequences: ESC P ... ST + .replace(/\x1bP[^\x1b]*\x1b\\/g, "") + // Remaining bare ESC sequences + .replace(/\x1b[^\[\]P]/g, "") + // Bare control characters (except newline/tab) + .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "") +} + export async function captureTmuxPane(paneId: string, lines = 15): Promise { try { const proc = spawn( @@ -38,7 +57,8 @@ export async function captureTmuxPane(paneId: string, lines = 15): Promise