From ff61f704b0c2f39b6f6952b5d797cb375e38cdd5 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 2 Apr 2026 19:25:55 +0900 Subject: [PATCH] fix: strip terminal probe escape sequences from tmux pane capture and reply input Terminal probe replies (DA, CPR, OSC color queries) from delegated pane startup were leaking into the main OpenCode chat input as garbled text. Root cause: captureTmuxPane() returned raw tmux capture-pane output containing ANSI CSI/OSC/DCS escape sequences, and sanitizeReplyInput() only stripped basic control characters (x00-x1f) but not escape sequences. Fix: - Add stripTerminalProbes() to tmux.ts that strips CSI, OSC, DCS, and bare ESC sequences while preserving normal text - Apply stripTerminalProbes() to captureTmuxPane() output before returning - Add ANSI/OSC/DCS stripping to sanitizeReplyInput() as defense-in-depth 11 new test cases covering DA, CPR, OSC, SGR, DCS, mixed sequences, and the exact bug report pattern. Closes #2887 --- .../terminal-probe-sanitization.test.ts | 135 ++++++++++++++++++ src/openclaw/reply-listener.ts | 9 ++ src/openclaw/tmux.ts | 22 ++- 3 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 src/openclaw/__tests__/terminal-probe-sanitization.test.ts 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