Skip to content

Commit 700a3e2

Browse files
olafuraclaude
andcommitted
fix(server): stop terminal escape sequences leaking as garbled text
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 "<m>;<v>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 <noreply@anthropic.com>
1 parent fb10345 commit 700a3e2

2 files changed

Lines changed: 381 additions & 40 deletions

File tree

apps/server/src/terminal/Manager.test.ts

Lines changed: 170 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as NodeServices from "@effect/platform-node/NodeServices";
2-
import { assert, it } from "@effect/vitest";
2+
import { assert, describe, it } from "@effect/vitest";
33
import {
44
DEFAULT_TERMINAL_ID,
55
type TerminalAttachStreamEvent,
@@ -28,6 +28,7 @@ import { expect } from "vite-plus/test";
2828

2929
import * as ProcessRunner from "../processRunner.ts";
3030
import * as TerminalManager from "./Manager.ts";
31+
import { sanitizeTerminalHistoryChunk } from "./Manager.ts";
3132
import * as PtyAdapter from "./PtyAdapter.ts";
3233

3334
class WaitForConditionError extends Data.TaggedError("WaitForConditionError")<{
@@ -991,6 +992,23 @@ it.layer(
991992
}),
992993
);
993994

995+
it.effect("sanitizes a pre-existing raw history log on load (older builds wrote it dirty)", () =>
996+
Effect.gen(function* () {
997+
const { manager, logsDir } = yield* createManager();
998+
const logPath = yield* historyLogPath(logsDir);
999+
// A log an older build persisted without sanitizing: the exact repeating
1000+
// DECRPM residue from #1238, ESC introducers intact. On load it must be
1001+
// stripped so it cannot replay (and re-trigger) at the prompt.
1002+
const garble =
1003+
"[?69;0$y[?2026;2$y[?2027;0$y[?2031;0$y[?2048;0$y";
1004+
yield* writeFileString(logPath, `prompt$ ${garble.repeat(15)}done\n`);
1005+
1006+
const opened = yield* manager.open(openInput());
1007+
assert.equal(opened.history, "prompt$ done\n");
1008+
// The cleaned history is persisted back, so it stays clean on re-read.
1009+
assert.equal(yield* readFileString(logPath), "prompt$ done\n");
1010+
}),
1011+
);
9941012
it.effect(
9951013
"preserves clear and style control sequences while dropping chunk-split query traffic",
9961014
() =>
@@ -1645,3 +1663,154 @@ it.layer(
16451663
}).pipe(Effect.provide(TestClock.layer())),
16461664
);
16471665
});
1666+
1667+
describe("sanitizeTerminalHistoryChunk", () => {
1668+
const sanitize = (data: string, pending = "") => sanitizeTerminalHistoryChunk(pending, data);
1669+
1670+
it("strips DECRPM mode reports (CSI ? Pm ; Ps $ y) from history", () => {
1671+
const reports = "\x1b[?69;0$y\x1b[?2026;2$y\x1b[?2048;0$y";
1672+
const { visibleText } = sanitize(`before${reports}after`);
1673+
assert.equal(visibleText, "beforeafter");
1674+
// The residue users were seeing must not survive.
1675+
assert.ok(!visibleText.includes("$y"));
1676+
assert.ok(!visibleText.includes("2026"));
1677+
});
1678+
1679+
it("strips DECRQM mode queries (CSI ? Pm $ p) so replay can't re-trigger them", () => {
1680+
const { visibleText } = sanitize("x\x1b[?2026$p\x1b[?2048$py");
1681+
assert.equal(visibleText, "xy");
1682+
});
1683+
1684+
it("keeps ordinary text and non-report CSI sequences", () => {
1685+
// SGR colour (m) and cursor moves stay; a plain 'p'/'y' without the `$`
1686+
// intermediate is not a mode sequence and must be preserved.
1687+
const { visibleText } = sanitize("\x1b[31mred\x1b[0m \x1b[2Aup happy");
1688+
assert.equal(visibleText, "\x1b[31mred\x1b[0m \x1b[2Aup happy");
1689+
});
1690+
1691+
it("drops the flattened mode-reply residue a shell echoes at the prompt", () => {
1692+
// The ESC introducer is already gone (the shell flattened the reply), so the
1693+
// escape-aware strip can't see it. A run of flattened DECRPM / DA / OSC-colour
1694+
// replies is dropped (DSR "n"/BEL/CR may separate them).
1695+
assert.equal(
1696+
sanitize("prompt$ 69;0$y2026;2$y2027;0$y2031;0$y2048;0$y").visibleText,
1697+
"prompt$ ",
1698+
);
1699+
assert.equal(sanitize("a 1;2c11;rgb:1616/1616/1616n1;2c b").visibleText, "a b");
1700+
// Lone DECRPM / OSC-colour tokens are distinctive enough to drop on their own.
1701+
assert.equal(sanitize("x 2026;2$y y").visibleText, "x y");
1702+
assert.equal(sanitize("c 4;0;rgb:1818/1e1e/2626 d").visibleText, "c d");
1703+
// Ambiguous lone tokens and ordinary words are preserved.
1704+
assert.equal(sanitize("see commit 1;2c now").visibleText, "see commit 1;2c now");
1705+
assert.equal(sanitize("running a connection").visibleText, "running a connection");
1706+
});
1707+
1708+
it("handles a report split across chunks via the pending buffer", () => {
1709+
const first = sanitize("tail\x1b[?69;0");
1710+
assert.equal(first.visibleText, "tail");
1711+
assert.notEqual(first.pendingControlSequence, "");
1712+
const second = sanitize("$ydone", first.pendingControlSequence);
1713+
assert.equal(second.visibleText, "done");
1714+
});
1715+
1716+
it("strips the real-world restore residue reported in issue #1238", () => {
1717+
// The exact escape-reply fragments a user saw flood the prompt on terminal
1718+
// restore: "2026;2$y2027;0$y2031;0$y2048;0$y1$r0m" — DECRPM mode reports
1719+
// (CSI ? Pm ; Ps $ y) plus a DECRPSS status reply (DCS Ps $ r D…D ST),
1720+
// reconstructed as the raw sequences the replayed history carried.
1721+
const residue =
1722+
"\x1b[?2026;2$y\x1b[?2027;0$y\x1b[?2031;0$y\x1b[?2048;0$y\x1bP1$r0m\x1b\\";
1723+
assert.equal(sanitize(`prompt$ ${residue}`).visibleText, "prompt$ ");
1724+
});
1725+
1726+
describe("responsesOnly (live stream)", () => {
1727+
const live = (data: string, pending = "") =>
1728+
sanitizeTerminalHistoryChunk(pending, data, { responsesOnly: true });
1729+
1730+
it("strips terminal responses (DA, DECRPM, cursor, DSR, OSC colour) that leak as garbage", () => {
1731+
const responses = "\x1b[?1;2c\x1b[?2026;2$y\x1b[2;5R\x1b[0n\x1b]11;rgb:1616/1616/1616\x07";
1732+
assert.equal(live(`a${responses}b`).visibleText, "ab");
1733+
});
1734+
1735+
it("keeps queries the client must still answer (DECRQM, DA, DSR, OSC colour)", () => {
1736+
const queries = "\x1b[?2026$p\x1b[c\x1b[6n\x1b]11;?\x07";
1737+
assert.equal(live(`x${queries}y`).visibleText, `x${queries}y`);
1738+
});
1739+
1740+
it("keeps ordinary display sequences", () => {
1741+
assert.equal(live("\x1b[31mred\x1b[0m up").visibleText, "\x1b[31mred\x1b[0m up");
1742+
});
1743+
1744+
it("relays a query split across chunks while history strips it", () => {
1745+
// The query (DECRQM `$p`) arrives in two pieces. The live view must relay
1746+
// it across the pending boundary; the scrollback view strips it.
1747+
const liveFirst = live("x\x1b[?2026");
1748+
assert.equal(liveFirst.visibleText, "x");
1749+
assert.notEqual(liveFirst.pendingControlSequence, "");
1750+
assert.equal(live("$py", liveFirst.pendingControlSequence).visibleText, "\x1b[?2026$py");
1751+
1752+
const histFirst = sanitize("x\x1b[?2026");
1753+
assert.equal(histFirst.visibleText, "x");
1754+
assert.equal(sanitize("$py", histFirst.pendingControlSequence).visibleText, "y");
1755+
});
1756+
1757+
it("diverges within one chunk: strips the response, relays the query", () => {
1758+
// `\x1b[0n` is a DSR *response* (stripped by both views); `\x1b[6n` is the
1759+
// cursor-position *query* the client must answer (relayed live, stripped
1760+
// from scrollback). Same input, two outputs from one parse.
1761+
const data = "A\x1b[0n B\x1b[6n C";
1762+
assert.equal(live(data).visibleText, "A B\x1b[6n C");
1763+
assert.equal(sanitize(data).visibleText, "A B C");
1764+
});
1765+
});
1766+
1767+
describe("8-bit C1 introducers", () => {
1768+
it("strips an 8-bit CSI DECRPM report (0x9b … $ y) like its ESC[ form", () => {
1769+
assert.equal(sanitize("a\x9b?2026;2$yb").visibleText, "ab");
1770+
// Live view strips the report too (it is a response, not a query).
1771+
assert.equal(
1772+
sanitizeTerminalHistoryChunk("", "a\x9b?2026;2$yb", { responsesOnly: true }).visibleText,
1773+
"ab",
1774+
);
1775+
});
1776+
1777+
it("strips an 8-bit OSC colour report (0x9d … BEL); relays the colour query live", () => {
1778+
assert.equal(sanitize("a\x9d11;rgb:1616/1616/1616\x07b").visibleText, "ab");
1779+
// The `?` colour query is relayed live (the client must answer it) but
1780+
// stripped from scrollback so a replay cannot re-trigger it.
1781+
assert.equal(
1782+
sanitizeTerminalHistoryChunk("", "a\x9d11;?\x07b", { responsesOnly: true }).visibleText,
1783+
"a\x9d11;?\x07b",
1784+
);
1785+
assert.equal(sanitize("a\x9d11;?\x07b").visibleText, "ab");
1786+
});
1787+
1788+
it("buffers an incomplete 8-bit CSI across chunks", () => {
1789+
const first = sanitize("tail\x9b?69;0");
1790+
assert.equal(first.visibleText, "tail");
1791+
assert.notEqual(first.pendingControlSequence, "");
1792+
assert.equal(sanitize("$ydone", first.pendingControlSequence).visibleText, "done");
1793+
});
1794+
});
1795+
1796+
describe("DCS status strings (DECRQSS / DECRPSS)", () => {
1797+
const live = (data: string) =>
1798+
sanitizeTerminalHistoryChunk("", data, { responsesOnly: true });
1799+
1800+
it("strips a DECRPSS status reply (DCS Ps $ r D…D ST) from both views", () => {
1801+
assert.equal(sanitize("a\x1bP1$r0m\x1b\\b").visibleText, "ab");
1802+
assert.equal(live("a\x1bP1$r0m\x1b\\b").visibleText, "ab");
1803+
});
1804+
1805+
it("relays a DECRQSS query (DCS $ q D…D ST) live but strips it from scrollback", () => {
1806+
assert.equal(live("a\x1bP$qm\x1b\\b").visibleText, "a\x1bP$qm\x1b\\b");
1807+
assert.equal(sanitize("a\x1bP$qm\x1b\\b").visibleText, "ab");
1808+
});
1809+
1810+
it("leaves other DCS strings (sixel, DECUDK) untouched", () => {
1811+
const sixel = "\x1bPq#0;2;0;0;0#0~~\x1b\\";
1812+
assert.equal(sanitize(`a${sixel}b`).visibleText, `a${sixel}b`);
1813+
assert.equal(live(`a${sixel}b`).visibleText, `a${sixel}b`);
1814+
});
1815+
});
1816+
});

0 commit comments

Comments
 (0)