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
This commit is contained in:
YeonGyu-Kim
2026-04-02 19:25:55 +09:00
parent 34a37dc946
commit ff61f704b0
3 changed files with 165 additions and 1 deletions
@@ -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")
})
})
})
+9
View File
@@ -225,6 +225,15 @@ export async function isDaemonRunning(): Promise<boolean> {
// 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, " ")
+21 -1
View File
@@ -25,6 +25,25 @@ export async function getTmuxSessionName(): Promise<string | null> {
}
}
/**
* 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 [ ... <letter> (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<string | null> {
try {
const proc = spawn(
@@ -38,7 +57,8 @@ export async function captureTmuxPane(paneId: string, lines = 15): Promise<strin
await proc.exited
const output = await outputPromise
if (proc.exitCode !== 0) return null
return output.trim() || null
const cleaned = stripTerminalProbes(output).trim()
return cleaned || null
} catch {
return null
}