Skip to content

Commit a522d32

Browse files
olafuraclaude
andcommitted
fix(server): stop terminal escape sequences leaking as garbled text
Returning to a terminal — and live use — sometimes painted garbage like "2026;2$y2027;0$y2031;0$y2048;0$y1$r0m", "1;2c", or "11;rgb:1616/1616/1616" onto the screen, in both the web and TUI (both render the server's sanitized terminal stream). This has been present for months — reported in #1238. The root cause: replayed scrollback carried terminal capability QUERIES, so on re-attach the client's emulator answered them again and the answers echoed at the shell prompt. The fix strips queries from scrollback (nothing left to re-trigger) and strips terminal responses from the live stream (which program output should never contain), while still relaying queries the client must answer so capability negotiation keeps working. Sequence classes handled, in one pass: - CSI: DECRQM mode query (CSI ? Pm $ p) and DECRPM report (CSI ? Pm ; Ps $ y), plus device-status (DSR), cursor-position (CPR), and device-attribute (DA) reports, and the 8-bit C1 (0x9b) form. - OSC: 10/11/12 colour query (`?`) and report (`rgb:`), 7-bit and 8-bit (0x9d). - DCS: DECRQSS status query (DCS $ q D…D ST) and DECRPSS reply (DCS Ps $ r D…D ST) — the `1$r0m` fragment in #1238 — while leaving other DCS (sixel, DECUDK) untouched. Queries are relayed on the live stream but stripped from scrollback; responses are stripped from both. Both views are produced by sanitizeTerminalChunkDual, which walks the chunk once and emits the scrollback and live text together (the pending incomplete-sequence boundary depends only on byte structure, so it is shared). The output handler runs it once per PTY event; the exported sanitizeTerminalHistoryChunk is a thin selector over it for the tests. Tests cover every class above for both views, 8-bit C1 introducers, queries and reports split across chunks, within-chunk divergence (response stripped from both, query relayed live but stripped from scrollback), and the exact residue string from #1238. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 28107e8 commit a522d32

2 files changed

Lines changed: 258 additions & 37 deletions

File tree

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

Lines changed: 136 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")<{
@@ -1645,3 +1646,137 @@ it.layer(
16451646
}).pipe(Effect.provide(TestClock.layer())),
16461647
);
16471648
});
1649+
1650+
describe("sanitizeTerminalHistoryChunk", () => {
1651+
const sanitize = (data: string, pending = "") => sanitizeTerminalHistoryChunk(pending, data);
1652+
1653+
it("strips DECRPM mode reports (CSI ? Pm ; Ps $ y) from history", () => {
1654+
const reports = "\x1b[?69;0$y\x1b[?2026;2$y\x1b[?2048;0$y";
1655+
const { visibleText } = sanitize(`before${reports}after`);
1656+
assert.equal(visibleText, "beforeafter");
1657+
// The residue users were seeing must not survive.
1658+
assert.ok(!visibleText.includes("$y"));
1659+
assert.ok(!visibleText.includes("2026"));
1660+
});
1661+
1662+
it("strips DECRQM mode queries (CSI ? Pm $ p) so replay can't re-trigger them", () => {
1663+
const { visibleText } = sanitize("x\x1b[?2026$p\x1b[?2048$py");
1664+
assert.equal(visibleText, "xy");
1665+
});
1666+
1667+
it("keeps ordinary text and non-report CSI sequences", () => {
1668+
// SGR colour (m) and cursor moves stay; a plain 'p'/'y' without the `$`
1669+
// intermediate is not a mode sequence and must be preserved.
1670+
const { visibleText } = sanitize("\x1b[31mred\x1b[0m \x1b[2Aup happy");
1671+
assert.equal(visibleText, "\x1b[31mred\x1b[0m \x1b[2Aup happy");
1672+
});
1673+
1674+
it("handles a report split across chunks via the pending buffer", () => {
1675+
const first = sanitize("tail\x1b[?69;0");
1676+
assert.equal(first.visibleText, "tail");
1677+
assert.notEqual(first.pendingControlSequence, "");
1678+
const second = sanitize("$ydone", first.pendingControlSequence);
1679+
assert.equal(second.visibleText, "done");
1680+
});
1681+
1682+
it("strips the real-world restore residue reported in issue #1238", () => {
1683+
// The exact escape-reply fragments a user saw flood the prompt on terminal
1684+
// restore: "2026;2$y2027;0$y2031;0$y2048;0$y1$r0m" — DECRPM mode reports
1685+
// (CSI ? Pm ; Ps $ y) plus a DECRPSS status reply (DCS Ps $ r D…D ST),
1686+
// reconstructed as the raw sequences the replayed history carried.
1687+
const residue =
1688+
"\x1b[?2026;2$y\x1b[?2027;0$y\x1b[?2031;0$y\x1b[?2048;0$y\x1bP1$r0m\x1b\\";
1689+
assert.equal(sanitize(`prompt$ ${residue}`).visibleText, "prompt$ ");
1690+
});
1691+
1692+
describe("responsesOnly (live stream)", () => {
1693+
const live = (data: string, pending = "") =>
1694+
sanitizeTerminalHistoryChunk(pending, data, { responsesOnly: true });
1695+
1696+
it("strips terminal responses (DA, DECRPM, cursor, DSR, OSC colour) that leak as garbage", () => {
1697+
const responses = "\x1b[?1;2c\x1b[?2026;2$y\x1b[2;5R\x1b[0n\x1b]11;rgb:1616/1616/1616\x07";
1698+
assert.equal(live(`a${responses}b`).visibleText, "ab");
1699+
});
1700+
1701+
it("keeps queries the client must still answer (DECRQM, DA, DSR, OSC colour)", () => {
1702+
const queries = "\x1b[?2026$p\x1b[c\x1b[6n\x1b]11;?\x07";
1703+
assert.equal(live(`x${queries}y`).visibleText, `x${queries}y`);
1704+
});
1705+
1706+
it("keeps ordinary display sequences", () => {
1707+
assert.equal(live("\x1b[31mred\x1b[0m up").visibleText, "\x1b[31mred\x1b[0m up");
1708+
});
1709+
1710+
it("relays a query split across chunks while history strips it", () => {
1711+
// The query (DECRQM `$p`) arrives in two pieces. The live view must relay
1712+
// it across the pending boundary; the scrollback view strips it.
1713+
const liveFirst = live("x\x1b[?2026");
1714+
assert.equal(liveFirst.visibleText, "x");
1715+
assert.notEqual(liveFirst.pendingControlSequence, "");
1716+
assert.equal(live("$py", liveFirst.pendingControlSequence).visibleText, "\x1b[?2026$py");
1717+
1718+
const histFirst = sanitize("x\x1b[?2026");
1719+
assert.equal(histFirst.visibleText, "x");
1720+
assert.equal(sanitize("$py", histFirst.pendingControlSequence).visibleText, "y");
1721+
});
1722+
1723+
it("diverges within one chunk: strips the response, relays the query", () => {
1724+
// `\x1b[0n` is a DSR *response* (stripped by both views); `\x1b[6n` is the
1725+
// cursor-position *query* the client must answer (relayed live, stripped
1726+
// from scrollback). Same input, two outputs from one parse.
1727+
const data = "A\x1b[0n B\x1b[6n C";
1728+
assert.equal(live(data).visibleText, "A B\x1b[6n C");
1729+
assert.equal(sanitize(data).visibleText, "A B C");
1730+
});
1731+
});
1732+
1733+
describe("8-bit C1 introducers", () => {
1734+
it("strips an 8-bit CSI DECRPM report (0x9b … $ y) like its ESC[ form", () => {
1735+
assert.equal(sanitize("a\x9b?2026;2$yb").visibleText, "ab");
1736+
// Live view strips the report too (it is a response, not a query).
1737+
assert.equal(
1738+
sanitizeTerminalHistoryChunk("", "a\x9b?2026;2$yb", { responsesOnly: true }).visibleText,
1739+
"ab",
1740+
);
1741+
});
1742+
1743+
it("strips an 8-bit OSC colour report (0x9d … BEL); relays the colour query live", () => {
1744+
assert.equal(sanitize("a\x9d11;rgb:1616/1616/1616\x07b").visibleText, "ab");
1745+
// The `?` colour query is relayed live (the client must answer it) but
1746+
// stripped from scrollback so a replay cannot re-trigger it.
1747+
assert.equal(
1748+
sanitizeTerminalHistoryChunk("", "a\x9d11;?\x07b", { responsesOnly: true }).visibleText,
1749+
"a\x9d11;?\x07b",
1750+
);
1751+
assert.equal(sanitize("a\x9d11;?\x07b").visibleText, "ab");
1752+
});
1753+
1754+
it("buffers an incomplete 8-bit CSI across chunks", () => {
1755+
const first = sanitize("tail\x9b?69;0");
1756+
assert.equal(first.visibleText, "tail");
1757+
assert.notEqual(first.pendingControlSequence, "");
1758+
assert.equal(sanitize("$ydone", first.pendingControlSequence).visibleText, "done");
1759+
});
1760+
});
1761+
1762+
describe("DCS status strings (DECRQSS / DECRPSS)", () => {
1763+
const live = (data: string) =>
1764+
sanitizeTerminalHistoryChunk("", data, { responsesOnly: true });
1765+
1766+
it("strips a DECRPSS status reply (DCS Ps $ r D…D ST) from both views", () => {
1767+
assert.equal(sanitize("a\x1bP1$r0m\x1b\\b").visibleText, "ab");
1768+
assert.equal(live("a\x1bP1$r0m\x1b\\b").visibleText, "ab");
1769+
});
1770+
1771+
it("relays a DECRQSS query (DCS $ q D…D ST) live but strips it from scrollback", () => {
1772+
assert.equal(live("a\x1bP$qm\x1b\\b").visibleText, "a\x1bP$qm\x1b\\b");
1773+
assert.equal(sanitize("a\x1bP$qm\x1b\\b").visibleText, "ab");
1774+
});
1775+
1776+
it("leaves other DCS strings (sixel, DECUDK) untouched", () => {
1777+
const sixel = "\x1bPq#0;2;0;0;0#0~~\x1b\\";
1778+
assert.equal(sanitize(`a${sixel}b`).visibleText, `a${sixel}b`);
1779+
assert.equal(live(`a${sixel}b`).visibleText, `a${sixel}b`);
1780+
});
1781+
});
1782+
});

0 commit comments

Comments
 (0)