From cc465ea6fdf5cc731daa1150e5976549a4a836e3 Mon Sep 17 00:00:00 2001 From: olafura Date: Mon, 22 Jun 2026 18:58:30 +0200 Subject: [PATCH] fix(server): stop terminal escape sequences leaking as garbled text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Returning to a terminal — and live use — leaked garbage like "69;0$y2026;2$y", "1;2c", "11;rgb:…" onto the screen (web and TUI both render the server's sanitized stream), and a prompt that re-queries on redraw could amplify it into a runaway flood. Reported in #1238. Fixed from both directions: - Input: the browser emulator auto-answers the program's capability queries (DECRPM/DA/DSR/OSC-colour) and emits focus events, sending them as PTY input; at an idle prompt the shell echoes them and the loop runs away. Strip that whole terminal→host response class from client input at the source (terminal.write) so it never reaches the shell. Cursor-position reports and bare query forms are kept (programs block on those). - Output / scrollback: one single-pass sanitizer (sanitizeTerminalChunkDual) emits both the scrollback view (drops queries AND responses, so a replay can't re-trigger an echo) and the live view (drops only responses, relays queries). Covers CSI (DECRQM "$p"/DECRPM "$y", DA, DSR, CPR, 8-bit C1), OSC 10/11/12 colour, and DCS (DECRQSS/DECRPSS), leaving sixel/DECUDK alone. - History on load: readHistory now sanitizes the persisted log (older builds wrote it raw), and also drops the *flattened* residue a shell echoes once the ESC introducer is gone — runs of DECRPM "$y" / DA ";c" / OSC colour (incl. OSC 4 palette), plus those distinctive tokens when isolated — which the escape-aware strip can't see. Ambiguous lone tokens and ordinary words are preserved. Validated against a real 1.5 GB dataset (970 KB polluted log: $y→0, rgb: 9070→<50, prompt text intact). Tests cover every class for both views, 8-bit C1, split-across-chunks, within-chunk divergence, the input strip, the flattened residue, and load-time sanitize of a raw #1238-residue log. Co-Authored-By: Claude Opus 4.8 --- apps/server/src/terminal/Manager.test.ts | 282 ++++++++++++++++++++- apps/server/src/terminal/Manager.ts | 310 ++++++++++++++++++++--- 2 files changed, 552 insertions(+), 40 deletions(-) diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 3a1cabc4a27..07affb54318 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -1,5 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, it } from "@effect/vitest"; +import { assert, describe, it } from "@effect/vitest"; import { DEFAULT_TERMINAL_ID, type TerminalAttachStreamEvent, @@ -28,6 +28,7 @@ import { expect } from "vite-plus/test"; import * as ProcessRunner from "../processRunner.ts"; import * as TerminalManager from "./Manager.ts"; +import { sanitizeTerminalHistoryChunk, stripTerminalResponsesFromInput } from "./Manager.ts"; import * as PtyAdapter from "./PtyAdapter.ts"; class WaitForConditionError extends Data.TaggedError("WaitForConditionError")<{ @@ -991,6 +992,23 @@ it.layer( }), ); + it.effect("sanitizes a pre-existing raw history log on load (older builds wrote it dirty)", () => + Effect.gen(function* () { + const { manager, logsDir } = yield* createManager(); + const logPath = yield* historyLogPath(logsDir); + // A log an older build persisted without sanitizing: the exact repeating + // DECRPM residue from #1238, ESC introducers intact. On load it must be + // stripped so it cannot replay (and re-trigger) at the prompt. + const garble = + "[?69;0$y[?2026;2$y[?2027;0$y[?2031;0$y[?2048;0$y"; + yield* writeFileString(logPath, `prompt$ ${garble.repeat(15)}done\n`); + + const opened = yield* manager.open(openInput()); + assert.equal(opened.history, "prompt$ done\n"); + // The cleaned history is persisted back, so it stays clean on re-read. + assert.equal(yield* readFileString(logPath), "prompt$ done\n"); + }), + ); it.effect( "preserves clear and style control sequences while dropping chunk-split query traffic", () => @@ -1645,3 +1663,265 @@ it.layer( }).pipe(Effect.provide(TestClock.layer())), ); }); + +describe("sanitizeTerminalHistoryChunk", () => { + const sanitize = (data: string, pending = "") => sanitizeTerminalHistoryChunk(pending, data); + + it("strips DECRPM mode reports (CSI ? Pm ; Ps $ y) from history", () => { + const reports = "\x1b[?69;0$y\x1b[?2026;2$y\x1b[?2048;0$y"; + const { visibleText } = sanitize(`before${reports}after`); + assert.equal(visibleText, "beforeafter"); + // The residue users were seeing must not survive. + assert.ok(!visibleText.includes("$y")); + assert.ok(!visibleText.includes("2026")); + }); + + it("strips DECRQM mode queries (CSI ? Pm $ p) so replay can't re-trigger them", () => { + const { visibleText } = sanitize("x\x1b[?2026$p\x1b[?2048$py"); + assert.equal(visibleText, "xy"); + }); + + it("keeps ordinary text and non-report CSI sequences", () => { + // SGR colour (m) and cursor moves stay; a plain 'p'/'y' without the `$` + // intermediate is not a mode sequence and must be preserved. + const { visibleText } = sanitize("\x1b[31mred\x1b[0m \x1b[2Aup happy"); + assert.equal(visibleText, "\x1b[31mred\x1b[0m \x1b[2Aup happy"); + }); + + it("drops the flattened mode-reply residue a shell echoes at the prompt", () => { + // The ESC introducer is already gone (the shell flattened the reply), so the + // escape-aware strip can't see it. A run of flattened DECRPM / DA / OSC-colour + // replies is dropped (DSR "n"/BEL/CR may separate them). + assert.equal( + sanitize("prompt$ 69;0$y2026;2$y2027;0$y2031;0$y2048;0$y").visibleText, + "prompt$ ", + ); + assert.equal(sanitize("a 1;2c11;rgb:1616/1616/1616n1;2c b").visibleText, "a b"); + // Lone DECRPM / OSC-colour / DECRPSS tokens are distinctive enough on their own. + assert.equal(sanitize("x 2026;2$y y").visibleText, "x y"); + assert.equal(sanitize("c 4;0;rgb:1818/1e1e/2626 d").visibleText, "c d"); + assert.equal(sanitize("tail 1$r0m end").visibleText, "tail end"); // flattened DECRPSS (#1238) + // Ambiguous lone tokens and ordinary words are preserved. + assert.equal(sanitize("see commit 1;2c now").visibleText, "see commit 1;2c now"); + assert.equal(sanitize("running a connection").visibleText, "running a connection"); + }); + + it("drops a flattened cursor-position-report (CPR) run, keeps a lone one", () => { + // The `;1RR`/`;R` flood from a prompt's CSI 6n re-query echoing at + // an idle prompt. Stripped as a run; a lone `;R` is ambiguous and kept. + assert.equal(sanitize(`prompt$ ${";1RR".repeat(40)}`).visibleText, "prompt$ "); + assert.equal(sanitize(`x ${"1;1R".repeat(20)} y`).visibleText, "x y"); + assert.equal(sanitize("at 12;5R done").visibleText, "at 12;5R done"); // lone, kept + }); + + it("does not over-match ordinary text that merely looks reply-shaped", () => { + // The colour alternative is pinned to OSC 10/11/12 and OSC 4 (`4;;`), so + // an arbitrary ";rgb:…" in program output survives. + assert.equal(sanitize("set 1;rgb:ff/00/00 now").visibleText, "set 1;rgb:ff/00/00 now"); + assert.equal(sanitize("hsl 7;rgb:aabbcc done").visibleText, "hsl 7;rgb:aabbcc done"); + // A DECRPM/DA token immediately followed by a word must not swallow its first + // letter (regression: a trailing "n?" used to eat the "n" of "next"). + assert.equal(sanitize("v 1;2$ynext").visibleText, "v next"); + // The DECRPSS payload is length-bounded so it can't eat a following number run. + assert.equal( + sanitize("tail 1$r0;120;340;Hello there").visibleText, + "tail 1$r0;120;340;Hello there", + ); + }); + + it("preserves a framed OSC 4 palette report instead of mangling its inner rgb", () => { + // The escape walk keeps a framed OSC 4 report (only OSC 10/11/12 are stripped), + // so the flattened pass must not delete the inner `4;;rgb:…` and leave a + // broken `ESC ] … ST` shell — in either view. The flattened (unframed) form is + // still dropped. + const framed = "\x1b]4;1;rgb:ff/00/00\x07"; + assert.equal(sanitize(`a ${framed} b`).visibleText, `a ${framed} b`); + assert.equal( + sanitizeTerminalHistoryChunk("", `a ${framed} b`, { responsesOnly: true }).visibleText, + `a ${framed} b`, + ); + assert.equal(sanitize("echo 4;1;rgb:ff/00/00 here").visibleText, "echo here"); + }); + + it("strips a huge adversarial ';'-run in linear time (no ReDoS)", () => { + // A program-controlled buffer of many ";" groups that never reaches + // "rgb:" used to drive catastrophic backtracking (tens of seconds). The + // pinned colour alternative makes this fail fast. + const evil = "1".repeat(20) + ";"; + const start = process.hrtime.bigint(); + sanitize(`${evil.repeat(16000)}rgb`); + const ms = Number(process.hrtime.bigint() - start) / 1e6; + assert.ok(ms < 1000, `flattened strip took ${ms}ms — possible ReDoS`); + }); + + it("handles a report split across chunks via the pending buffer", () => { + const first = sanitize("tail\x1b[?69;0"); + assert.equal(first.visibleText, "tail"); + assert.notEqual(first.pendingControlSequence, ""); + const second = sanitize("$ydone", first.pendingControlSequence); + assert.equal(second.visibleText, "done"); + }); + + it("self-heals a flattened token split across PTY chunks on the next reload", () => { + // A *flattened* reply (ESC introducer already gone) has no escape framing for + // the pending buffer to hold, so if a PTY read splits it mid-token both halves + // are written to history live (transient garble). But they land contiguously, + // so readHistory()'s whole-buffer sanitize rejoins and strips them on restore — + // the residue does not return after a restart (the persistence concern in the + // cross-chunk #1238 follow-up). + const live = sanitize("prompt$ 2026;2").visibleText + sanitize("$y done").visibleText; + assert.equal(live, "prompt$ 2026;2$y done"); // contiguous in the persisted log + assert.equal(sanitize(live).visibleText, "prompt$ done"); // stripped on reload + }); + + it("strips the real-world restore residue reported in issue #1238", () => { + // The exact escape-reply fragments a user saw flood the prompt on terminal + // restore: "2026;2$y2027;0$y2031;0$y2048;0$y1$r0m" — DECRPM mode reports + // (CSI ? Pm ; Ps $ y) plus a DECRPSS status reply (DCS Ps $ r D…D ST), + // reconstructed as the raw sequences the replayed history carried. + const residue = + "\x1b[?2026;2$y\x1b[?2027;0$y\x1b[?2031;0$y\x1b[?2048;0$y\x1bP1$r0m\x1b\\"; + assert.equal(sanitize(`prompt$ ${residue}`).visibleText, "prompt$ "); + }); + + describe("responsesOnly (live stream)", () => { + const live = (data: string, pending = "") => + sanitizeTerminalHistoryChunk(pending, data, { responsesOnly: true }); + + it("strips terminal responses (DA, DECRPM, cursor, DSR, OSC colour) that leak as garbage", () => { + const responses = "\x1b[?1;2c\x1b[?2026;2$y\x1b[2;5R\x1b[0n\x1b]11;rgb:1616/1616/1616\x07"; + assert.equal(live(`a${responses}b`).visibleText, "ab"); + }); + + it("keeps queries the client must still answer (DECRQM, DA, DSR, OSC colour)", () => { + const queries = "\x1b[?2026$p\x1b[c\x1b[6n\x1b]11;?\x07"; + assert.equal(live(`x${queries}y`).visibleText, `x${queries}y`); + }); + + it("keeps ordinary display sequences", () => { + assert.equal(live("\x1b[31mred\x1b[0m up").visibleText, "\x1b[31mred\x1b[0m up"); + }); + + it("relays a query split across chunks while history strips it", () => { + // The query (DECRQM `$p`) arrives in two pieces. The live view must relay + // it across the pending boundary; the scrollback view strips it. + const liveFirst = live("x\x1b[?2026"); + assert.equal(liveFirst.visibleText, "x"); + assert.notEqual(liveFirst.pendingControlSequence, ""); + assert.equal(live("$py", liveFirst.pendingControlSequence).visibleText, "\x1b[?2026$py"); + + const histFirst = sanitize("x\x1b[?2026"); + assert.equal(histFirst.visibleText, "x"); + assert.equal(sanitize("$py", histFirst.pendingControlSequence).visibleText, "y"); + }); + + it("diverges within one chunk: strips the response, relays the query", () => { + // `\x1b[0n` is a DSR *response* (stripped by both views); `\x1b[6n` is the + // cursor-position *query* the client must answer (relayed live, stripped + // from scrollback). Same input, two outputs from one parse. + const data = "A\x1b[0n B\x1b[6n C"; + assert.equal(live(data).visibleText, "A B\x1b[6n C"); + assert.equal(sanitize(data).visibleText, "A B C"); + }); + }); + + describe("8-bit C1 introducers", () => { + it("strips an 8-bit CSI DECRPM report (0x9b … $ y) like its ESC[ form", () => { + assert.equal(sanitize("a\x9b?2026;2$yb").visibleText, "ab"); + // Live view strips the report too (it is a response, not a query). + assert.equal( + sanitizeTerminalHistoryChunk("", "a\x9b?2026;2$yb", { responsesOnly: true }).visibleText, + "ab", + ); + }); + + it("strips an 8-bit OSC colour report (0x9d … BEL); relays the colour query live", () => { + assert.equal(sanitize("a\x9d11;rgb:1616/1616/1616\x07b").visibleText, "ab"); + // The `?` colour query is relayed live (the client must answer it) but + // stripped from scrollback so a replay cannot re-trigger it. + assert.equal( + sanitizeTerminalHistoryChunk("", "a\x9d11;?\x07b", { responsesOnly: true }).visibleText, + "a\x9d11;?\x07b", + ); + assert.equal(sanitize("a\x9d11;?\x07b").visibleText, "ab"); + }); + + it("buffers an incomplete 8-bit CSI across chunks", () => { + const first = sanitize("tail\x9b?69;0"); + assert.equal(first.visibleText, "tail"); + assert.notEqual(first.pendingControlSequence, ""); + assert.equal(sanitize("$ydone", first.pendingControlSequence).visibleText, "done"); + }); + }); + + describe("DCS status strings (DECRQSS / DECRPSS)", () => { + const live = (data: string) => + sanitizeTerminalHistoryChunk("", data, { responsesOnly: true }); + + it("strips a DECRPSS status reply (DCS Ps $ r D…D ST) from both views", () => { + assert.equal(sanitize("a\x1bP1$r0m\x1b\\b").visibleText, "ab"); + assert.equal(live("a\x1bP1$r0m\x1b\\b").visibleText, "ab"); + }); + + it("relays a DECRQSS query (DCS $ q D…D ST) live but strips it from scrollback", () => { + assert.equal(live("a\x1bP$qm\x1b\\b").visibleText, "a\x1bP$qm\x1b\\b"); + assert.equal(sanitize("a\x1bP$qm\x1b\\b").visibleText, "ab"); + }); + + it("leaves other DCS strings (sixel, DECUDK) untouched", () => { + const sixel = "\x1bPq#0;2;0;0;0#0~~\x1b\\"; + assert.equal(sanitize(`a${sixel}b`).visibleText, `a${sixel}b`); + assert.equal(live(`a${sixel}b`).visibleText, `a${sixel}b`); + }); + }); +}); + +describe("stripTerminalResponsesFromInput", () => { + it("drops the browser's auto-replies that drive the echo loop", () => { + const flood = + "\x1b[?69;0$y\x1b[?2026;2$y\x1b[?1;2c\x1b]11;rgb:1616/1616/1616\x1b\\\x1b[0n\x1bP1$r0m\x1b\\\x1b[>0;276;0c"; + assert.equal(stripTerminalResponsesFromInput(flood), ""); + }); + + it("accepts the 8-bit ST (0x9c) terminator for OSC/DCS replies", () => { + assert.equal(stripTerminalResponsesFromInput("\x1b]11;rgb:1616/1616/1616\x9c"), ""); + assert.equal(stripTerminalResponsesFromInput("\x1bP1$r0m\x9c"), ""); + }); + + it("strips OSC 4 palette colour replies so they can't re-arm the echo loop", () => { + assert.equal(stripTerminalResponsesFromInput("\x1b]4;1;rgb:1616/1616/1616\x07"), ""); + assert.equal(stripTerminalResponsesFromInput("\x1b]4;255;rgb:ffff/0000/0000\x1b\\"), ""); + }); + + it("strips replies that use 8-bit C1 introducers (0x9b CSI, 0x9d OSC, 0x90 DCS)", () => { + assert.equal(stripTerminalResponsesFromInput("\x9b?69;0$y"), ""); // C1 CSI DECRPM + assert.equal(stripTerminalResponsesFromInput("\x9b>0;276;0c"), ""); // C1 CSI secondary DA + assert.equal(stripTerminalResponsesFromInput("\x9d4;1;rgb:1616/1616/1616\x9c"), ""); // C1 OSC 4 + C1 ST + assert.equal(stripTerminalResponsesFromInput("\x901$r0m\x9c"), ""); // C1 DCS DECRPSS + }); + + it("keeps focus events so DECSET ?1004 programs (vim/tmux) still receive them", () => { + assert.equal(stripTerminalResponsesFromInput("\x1b[I"), "\x1b[I"); // focus in + assert.equal(stripTerminalResponsesFromInput("\x1b[O"), "\x1b[O"); // focus out + }); + + it("strips cursor-position report (CPR) replies that drive the prompt redraw flood", () => { + assert.equal(stripTerminalResponsesFromInput("\x1b[1;1R"), ""); // CPR reply + assert.equal(stripTerminalResponsesFromInput("\x1b[;1R"), ""); // empty-row CPR + assert.equal(stripTerminalResponsesFromInput("\x1b[1;1R\x1b[1;1R\x1b[1;1R"), ""); // flood + assert.equal(stripTerminalResponsesFromInput("\x9b5;10R"), ""); // 8-bit C1 CPR + }); + + it("keeps real user input, cursor moves, and bare query forms", () => { + assert.equal(stripTerminalResponsesFromInput("ls -la\r"), "ls -la\r"); // keystrokes + assert.equal( + stripTerminalResponsesFromInput("\x1b[A\x1b[B\x1b[C\x1b[D"), + "\x1b[A\x1b[B\x1b[C\x1b[D", + ); // arrows + assert.equal(stripTerminalResponsesFromInput("\x03"), "\x03"); // Ctrl-C + assert.equal(stripTerminalResponsesFromInput("\x1b[1;5H"), "\x1b[1;5H"); // cursor-move (H, not CPR) + assert.equal(stripTerminalResponsesFromInput("\x1b[c"), "\x1b[c"); // bare DA query kept + assert.equal(stripTerminalResponsesFromInput("\x1b[>c"), "\x1b[>c"); // bare secondary DA query kept + assert.equal(stripTerminalResponsesFromInput("\x1b[6n"), "\x1b[6n"); // DSR query kept + }); +}); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 6347fdfc64d..218ab5bad5e 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -868,21 +868,71 @@ function isCsiFinalByte(codePoint: number): boolean { return codePoint >= 0x40 && codePoint <= 0x7e; } -function shouldStripCsiSequence(body: string, finalByte: string): boolean { - if (finalByte === "n") { - return true; - } +/** + * Whether a CSI sequence should be dropped from the sanitized terminal stream. + * + * `responsesOnly` strips ONLY terminal→host *responses* (spurious echo a + * program's output should never contain) while leaving host→terminal *queries* + * intact — the live stream uses it so the client's emulator still receives the + * queries it must answer (DA, DSR, DECRQM, OSC colour). The default + * (responsesOnly=false, for replayed scrollback) also strips queries, so a + * replay can't re-trigger one whose answer would echo at the prompt. + */ +function shouldStripCsiSequence(body: string, finalByte: string, responsesOnly = false): boolean { + // Cursor-position report (CSI r;c R) — always a response. if (finalByte === "R" && /^[0-9;?]*$/.test(body)) { return true; } - if (finalByte === "c" && /^[>0-9;?]*$/.test(body)) { + // Device-status report. `0 n`/`3 n` (optionally DEC `?`) are responses; `5 n`/ + // `6 n` are queries the client must still answer. + if (finalByte === "n") { + return responsesOnly ? /^\??[03]$/.test(body) : true; + } + // Device attributes. `CSI ? … c` / `CSI > … ; … c` are responses; the bare + // `CSI c` / `CSI 0 c` / `CSI > c` forms are queries. + if (finalByte === "c") { + if (responsesOnly) return /^\?[0-9;]+$/.test(body) || /^>[0-9]+;[0-9;]+$/.test(body); + return /^[>0-9;?]*$/.test(body); + } + // DECRPM mode report (CSI ? Pm ; Ps $ y) — a response. The `$ p` form is the + // DECRQM query, kept in the live stream. The `$` intermediate distinguishes + // these from ordinary `p`/`y`-final CSIs. + if (finalByte === "y" && /^[?0-9;]*\$$/.test(body)) { return true; } + if (finalByte === "p" && /^[?0-9;]*\$$/.test(body)) { + return !responsesOnly; + } return false; } -function shouldStripOscSequence(content: string): boolean { - return /^(10|11|12);(?:\?|rgb:)/.test(content); +/** + * Whether an OSC sequence should be dropped. Only OSC 10/11/12 colour is + * sanitized: `rgb:…` is a terminal response, `?` is a host query (kept in the + * live stream when `responsesOnly` is set so the client still answers it). + */ +function shouldStripOscSequence(content: string, responsesOnly = false): boolean { + // OSC 10/11/12 colour: `rgb:…` is a response; `?` is a query. + return responsesOnly ? /^(10|11|12);rgb:/.test(content) : /^(10|11|12);(?:\?|rgb:)/.test(content); +} + +/** + * Whether a DCS sequence should be dropped (the `$` intermediate marks the + * capability-negotiation forms, as with CSI): + * - DECRPSS status reply (DCS Ps $ r D…D ST) — a terminal→host response, + * stripped from both views (it must never survive as visible text). + * - DECRQSS status query (DCS $ q D…D ST) — a host→terminal query, stripped + * from scrollback so a replay can't re-trigger it, but relayed live so the + * client still answers. Other DCS (sixel, DECUDK, …) is left untouched. + */ +function shouldStripDcsSequence(content: string, responsesOnly = false): boolean { + if (/^[01]?\$r/.test(content)) { + return true; + } + if (/^\$q/.test(content)) { + return !responsesOnly; + } + return false; } function stripStringTerminator(value: string): string { @@ -928,17 +978,162 @@ function findEscapeSequenceEndIndex(input: string, start: number): number | null return isEscapeFinalByte(input.charCodeAt(cursor)) ? cursor + 1 : start + 1; } -function sanitizeTerminalHistoryChunk( +// A flattened terminal-reply fragment (the ESC introducer is already gone): +// DECRPM ";$y", device-attributes ";c", OSC 10/11/12 or OSC 4 palette +// colour ("1[012];rgb:…" / "4;;rgb:…"), or DECRPSS "<0|1>$r" (e.g. +// the "1$r0m" tail from #1238). +// +// The colour alternative is pinned to the real OSC numbers (10/11/12, or 4 with +// an index) rather than an unbounded "(?:[0-9]+;)+" run — that both stops it +// matching ordinary ";rgb:…" text and removes a catastrophic-backtracking +// (ReDoS) path on a long ";"-separated digit run that never reaches "rgb:". The +// DECRPSS setting is length-bounded for the same reason and to stop it eating a +// following digit/semicolon run of legitimate text. +// +// The negative lookbehind skips a colour run that is still inside an intact OSC +// frame: the escape-aware walk keeps a framed OSC 4 palette report (`shouldStrip +// OscSequence` only strips OSC 10/11/12), so without this guard the flattened +// pass would delete the inner `4;;rgb:…` and leave a broken `ESC ] … ST` +// shell. Flattened residue has no introducer, so this only excludes framed ones. +const FLATTENED_OSC_COLOUR = "(?; R`) — e.g. the +// ";R" / ";1RR" runs a `CSI 6 n` query produces when the emulator's +// reply echoes at an idle prompt. Row is optional and the echoed "R" can double, +// so allow `[0-9]*;[0-9]+R+`. Only stripped in a RUN (like the DA `c` form) — a +// lone `;R` is too ambiguous to drop on its own. +const FLATTENED_CPR = "[0-9]*;[0-9]+R+"; +const FLATTENED_FRAGMENT = `(?:[0-9]+;[0-9]+\\$y|[0-9]+;[0-9]+c|${FLATTENED_CPR}|${FLATTENED_OSC_COLOUR}|${FLATTENED_DECRPSS})`; +const FLATTENED_REPLY_RUN = new RegExp( + `${FLATTENED_FRAGMENT}(?:[\\x07\\r n]{0,8}${FLATTENED_FRAGMENT})+`, + "g", +); +const FLATTENED_REPLY_TOKEN = new RegExp( + `(?:${FLATTENED_OSC_COLOUR}|[0-9]+;[0-9]+\\$y|${FLATTENED_DECRPSS})`, + "g", +); +/** + * Drop the flattened terminal-reply residue a shell echoes at the prompt. + * + * When a capability reply lands at an idle prompt the shell echoes its + * *flattened* parameters as visible text (the ESC introducer is already gone, so + * the escape-aware strip can't see it). Two passes: drop a run of 2+ flattened + * fragments (DSR "n"/BEL/CR may separate them), then drop the unambiguous + * OSC-colour / DECRPM / DECRPSS tokens even when isolated. Ambiguous lone + * ";c" / "n" forms and ordinary words (e.g. "running", "1;2c") are kept. + */ +function stripFlattenedModeReplyResidue(text: string): string { + // Every fragment contains either ";" (DECRPM/DA/OSC) or "$r" (DECRPSS), so text + // with neither can't hold residue — skip the regexes. + if (!text.includes(";") && !text.includes("$r")) { + return text; + } + return text.replace(FLATTENED_REPLY_RUN, "").replace(FLATTENED_REPLY_TOKEN, ""); +} + +// Matches the terminal→host response sequences the browser emulator +// auto-generates in answer to a program's capability queries: DECRPM "$y", +// device-attributes "c", device-status "0n"/"3n", cursor-position report +// ";R" (CPR), OSC 10/11/12 + OSC 4 palette colour, and DECRPSS "$r". +// Each introducer accepts both the 7-bit ESC form and the 8-bit C1 byte (CSI +// 0x9b, OSC 0x9d, DCS 0x90), and each terminator the BEL, ESC\, or 8-bit ST +// (0x9c) — matching the output sanitizer so a C1-encoded reply can't slip past. +// +// CPR (`CSI ; R`) IS stripped: like the other capability replies it is +// an emulator auto-answer (to `CSI 6 n`), and a prompt that re-queries on redraw +// makes the echoed reply the worst runaway-flood source (issue: a prompt's +// `;1RR` flood). The `;`-separated two-parameter form is required, so it never +// matches a single keystroke or a bare `CSI R`. The bare DSR query forms are +// kept — the DA alternation requires a parameter so `CSI ? c` / `CSI > c` and +// `CSI 6 n` queries pass through. +// +// Focus in/out (CSI I / CSI O) are NOT stripped: a program that enabled focus +// reporting (DECSET ?1004 — vim, tmux) legitimately expects them, and they are +// user-action-driven so they never feed the runaway redraw-requery loop the +// capability responses do. +const INPUT_CSI = "(?:\\x1b\\[|\\x9b)"; +const INPUT_OSC = "(?:\\x1b\\]|\\x9d)"; +const INPUT_DCS = "(?:\\x1bP|\\x90)"; +const INPUT_ST = "(?:\\x07|\\x1b\\\\|\\x9c)"; +const INPUT_TERMINAL_RESPONSE = new RegExp( + [ + `${INPUT_CSI}\\?[0-9;]*\\$y`, + `${INPUT_CSI}[?>][0-9;]+c`, + `${INPUT_CSI}\\??[03]n`, + `${INPUT_CSI}[0-9]*;[0-9]+R`, + `${INPUT_OSC}(?:1[012];|4;[0-9]+;)rgb:[0-9a-fA-F/]*${INPUT_ST}`, + `${INPUT_DCS}[01]?\\$r[^\\x1b\\x07\\x9c]*${INPUT_ST}`, + ].join("|"), + "g", +); +/** + * Strip the browser emulator's auto-generated terminal responses from client + * input before it reaches the PTY. + * + * The emulator answers the program's capability queries (DECRPM, device + * attributes, device status, cursor position, OSC colour) and emits focus + * events, sending them all as input. At an idle prompt the shell has no reader + * for them, so it echoes them — and a prompt that re-queries on redraw turns + * that into a runaway feedback loop. A user never types these, so dropping them + * at the source breaks the loop. The cursor-position report (CPR) is the most + * aggressive offender (a prompt's `;1RR` flood), so its two-parameter + * `CSI ; R` reply is stripped too; only the bare query forms + * (`CSI 6 n`, `CSI ? c`) are kept. Exported for unit testing. + */ +export function stripTerminalResponsesFromInput(data: string): string { + // Skip the regex unless the data carries a 7-bit ESC or one of the 8-bit C1 + // introducers (CSI 0x9b, OSC 0x9d, DCS 0x90) a response could start with. + const hasIntroducer = + data.includes("\x1b") || + data.includes("\x9b") || + data.includes("\x9d") || + data.includes("\x90"); + return hasIntroducer ? data.replace(INPUT_TERMINAL_RESPONSE, "") : data; +} + +/** + * Single parse of a chunk that produces BOTH sanitized views at once: + * - `historyText`: the scrollback strip — drops terminal queries AND responses + * so a replay can never re-trigger a query whose answer would echo. + * - `liveText`: the live-stream strip — drops only terminal→host responses + * (spurious echo) while relaying host→terminal queries the client answers. + * + * Both share one walk and one pending-sequence boundary: the boundary depends + * only on byte structure (where an incomplete escape sequence ends), never on + * which complete sequences are stripped, so it is identical for both views. + */ +function sanitizeTerminalChunkDual( pendingControlSequence: string, data: string, -): { visibleText: string; pendingControlSequence: string } { +): { historyText: string; liveText: string; pendingControlSequence: string } { const input = `${pendingControlSequence}${data}`; - let visibleText = ""; + let historyText = ""; + let liveText = ""; let index = 0; - const append = (value: string) => { - visibleText += value; + // Ordinary text and sequences neither view strips go to both buffers. + const appendBoth = (value: string) => { + historyText += value; + liveText += value; + }; + // A CSI sequence: each view keeps it unless its own strip rule removes it. + const appendCsi = (sequence: string, body: string, finalByte: string) => { + if (!shouldStripCsiSequence(body, finalByte, false)) historyText += sequence; + if (!shouldStripCsiSequence(body, finalByte, true)) liveText += sequence; + }; + const appendOsc = (sequence: string, content: string) => { + if (!shouldStripOscSequence(content, false)) historyText += sequence; + if (!shouldStripOscSequence(content, true)) liveText += sequence; }; + const appendDcs = (sequence: string, content: string) => { + if (!shouldStripDcsSequence(content, false)) historyText += sequence; + if (!shouldStripDcsSequence(content, true)) liveText += sequence; + }; + const pending = () => ({ + historyText: stripFlattenedModeReplyResidue(historyText), + liveText: stripFlattenedModeReplyResidue(liveText), + pendingControlSequence: input.slice(index), + }); while (index < input.length) { const codePoint = input.charCodeAt(index); @@ -946,7 +1141,7 @@ function sanitizeTerminalHistoryChunk( if (codePoint === 0x1b) { const nextCodePoint = input.charCodeAt(index + 1); if (Number.isNaN(nextCodePoint)) { - return { visibleText, pendingControlSequence: input.slice(index) }; + return pending(); } if (nextCodePoint === 0x5b) { @@ -955,16 +1150,14 @@ function sanitizeTerminalHistoryChunk( if (isCsiFinalByte(input.charCodeAt(cursor))) { const sequence = input.slice(index, cursor + 1); const body = input.slice(index + 2, cursor); - if (!shouldStripCsiSequence(body, input[cursor] ?? "")) { - append(sequence); - } + appendCsi(sequence, body, input[cursor] ?? ""); index = cursor + 1; break; } cursor += 1; } if (cursor >= input.length) { - return { visibleText, pendingControlSequence: input.slice(index) }; + return pending(); } continue; } @@ -977,12 +1170,16 @@ function sanitizeTerminalHistoryChunk( ) { const terminatorIndex = findStringTerminatorIndex(input, index + 2); if (terminatorIndex === null) { - return { visibleText, pendingControlSequence: input.slice(index) }; + return pending(); } const sequence = input.slice(index, terminatorIndex); const content = stripStringTerminator(input.slice(index + 2, terminatorIndex)); - if (nextCodePoint !== 0x5d || !shouldStripOscSequence(content)) { - append(sequence); + if (nextCodePoint === 0x5d) { + appendOsc(sequence, content); + } else if (nextCodePoint === 0x50) { + appendDcs(sequence, content); + } else { + appendBoth(sequence); } index = terminatorIndex; continue; @@ -990,9 +1187,9 @@ function sanitizeTerminalHistoryChunk( const escapeSequenceEndIndex = findEscapeSequenceEndIndex(input, index + 1); if (escapeSequenceEndIndex === null) { - return { visibleText, pendingControlSequence: input.slice(index) }; + return pending(); } - append(input.slice(index, escapeSequenceEndIndex)); + appendBoth(input.slice(index, escapeSequenceEndIndex)); index = escapeSequenceEndIndex; continue; } @@ -1003,16 +1200,14 @@ function sanitizeTerminalHistoryChunk( if (isCsiFinalByte(input.charCodeAt(cursor))) { const sequence = input.slice(index, cursor + 1); const body = input.slice(index + 1, cursor); - if (!shouldStripCsiSequence(body, input[cursor] ?? "")) { - append(sequence); - } + appendCsi(sequence, body, input[cursor] ?? ""); index = cursor + 1; break; } cursor += 1; } if (cursor >= input.length) { - return { visibleText, pendingControlSequence: input.slice(index) }; + return pending(); } continue; } @@ -1020,22 +1215,49 @@ function sanitizeTerminalHistoryChunk( if (codePoint === 0x9d || codePoint === 0x90 || codePoint === 0x9e || codePoint === 0x9f) { const terminatorIndex = findStringTerminatorIndex(input, index + 1); if (terminatorIndex === null) { - return { visibleText, pendingControlSequence: input.slice(index) }; + return pending(); } const sequence = input.slice(index, terminatorIndex); const content = stripStringTerminator(input.slice(index + 1, terminatorIndex)); - if (codePoint !== 0x9d || !shouldStripOscSequence(content)) { - append(sequence); + if (codePoint === 0x9d) { + appendOsc(sequence, content); + } else if (codePoint === 0x90) { + appendDcs(sequence, content); + } else { + appendBoth(sequence); } index = terminatorIndex; continue; } - append(input[index] ?? ""); + appendBoth(input[index] ?? ""); index += 1; } - return { visibleText, pendingControlSequence: "" }; + return { + historyText: stripFlattenedModeReplyResidue(historyText), + liveText: stripFlattenedModeReplyResidue(liveText), + pendingControlSequence: "", + }; +} + +/** + * Sanitize one chunk of terminal output. `responsesOnly` selects the live-stream + * view (strips only terminal responses, relaying queries the client answers); + * the default selects the scrollback view (also strips queries). Both are + * computed in one pass — see {@link sanitizeTerminalChunkDual}. Exported for unit + * testing. + */ +export function sanitizeTerminalHistoryChunk( + pendingControlSequence: string, + data: string, + options: { readonly responsesOnly?: boolean } = {}, +): { visibleText: string; pendingControlSequence: string } { + const dual = sanitizeTerminalChunkDual(pendingControlSequence, data); + return { + visibleText: (options.responsesOnly ?? false) ? dual.liveText : dual.historyText, + pendingControlSequence: dual.pendingControlSequence, + }; } function legacySafeThreadId(threadId: string): string { @@ -1404,7 +1626,11 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func (cause) => new TerminalHistoryError({ operation: "read", threadId, terminalId, cause }), ), ); - const capped = capHistory(raw, historyLineLimit); + // Sanitize on load so terminal query/response residue persisted by older + // builds (the "…$y" / colour-report garble) is stripped from replayed + // scrollback — not just from newly-written output. Idempotent for clean + // logs; the rewrite below persists the cleanup. + const capped = capHistory(sanitizeTerminalHistoryChunk("", raw).visibleText, historyLineLimit); if (capped !== raw) { yield* fileSystem .writeFileString(nextPath, capped) @@ -1444,7 +1670,8 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func new TerminalHistoryError({ operation: "migrate", threadId, terminalId, cause }), ), ); - const capped = capHistory(raw, historyLineLimit); + // Sanitize while migrating so the new-path log starts clean (see above). + const capped = capHistory(sanitizeTerminalHistoryChunk("", raw).visibleText, historyLineLimit); yield* fileSystem .writeFileString(nextPath, capped) .pipe( @@ -1624,14 +1851,17 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func } if (nextEvent.type === "output") { - const sanitized = sanitizeTerminalHistoryChunk( + // One parse yields both views: the scrollback strip (drops queries and + // responses) feeds history; the live strip (drops only responses, + // relaying queries the client answers) feeds the streamed data. + const sanitized = sanitizeTerminalChunkDual( session.pendingHistoryControlSequence, nextEvent.data, ); session.pendingHistoryControlSequence = sanitized.pendingControlSequence; - if (sanitized.visibleText.length > 0) { + if (sanitized.historyText.length > 0) { session.history = capHistory( - `${session.history}${sanitized.visibleText}`, + `${session.history}${sanitized.historyText}`, historyLineLimit, ); } @@ -1642,8 +1872,8 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func threadId: session.threadId, terminalId: session.terminalId, sequence: eventStamp.sequence, - history: sanitized.visibleText.length > 0 ? session.history : null, - data: nextEvent.data, + history: sanitized.historyText.length > 0 ? session.history : null, + data: sanitized.liveText, } as const; } @@ -2452,8 +2682,10 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func terminalId, }); } + const data = stripTerminalResponsesFromInput(input.data); + if (data.length === 0) return; yield* Effect.try({ - try: () => process.write(input.data), + try: () => process.write(data), catch: (cause) => new TerminalWriteError({ threadId: input.threadId,