Skip to content

fix(server): stop garbled escape sequences leaking into the terminal#3508

Open
olafura wants to merge 1 commit into
pingdotgg:mainfrom
olafura:fix/terminal-garbled-output
Open

fix(server): stop garbled escape sequences leaking into the terminal#3508
olafura wants to merge 1 commit into
pingdotgg:mainfrom
olafura:fix/terminal-garbled-output

Conversation

@olafura

@olafura olafura commented Jun 22, 2026

Copy link
Copy Markdown

What Changed

The server's terminal output now filters out the invisible terminal "control codes" that were leaking onto the screen as garbage (e.g. 2026;2$y2027;0$y2031;0$y2048;0$y1$r0m, 1;2c, 11;rgb:…) when you reopen or restore a terminal.

It handles the three families these codes come in — CSI (…$y/…$p), OSC (colour rgb:/?), and DCS ($r/$q, the 1$r0m piece in #1238) — always stripping the machine answers, stripping the questions from saved scrollback, and still passing the questions through during live use so the terminal can keep negotiating features normally. Both views are produced in a single parse of each output chunk.

Why

Programs and the terminal constantly exchange invisible control codes. Some are questions the program asks ("do you support this mode?", "where is the cursor?"); the terminal replies with an answer that is meant to be machine-readable, not shown.

The bug: the server was saving those questions into the terminal's scrollback history. When you reopened or restored a terminal (toggling it with Cmd+J, switching projects — see the repro in #1238), the history was replayed, the questions got asked again, and the answers got printed as visible junk at the shell prompt.

The fix removes the questions from saved history (so a replay can't trigger fresh answers) and removes stray answers from the live stream (normal program output never contains them). The web app and the TUI both render this same server-sanitized stream, so this fixes it in both.

UI Changes

No UI code changed — this is a server-side output filter. The visible symptom and a video are in #1238; the exact gibberish string from that report is now covered by a regression test.

Checklist

  • This PR is small and focused (2 files, server-side; the bulk is tests)
  • I explained what changed and why
  • before/after screenshots for UI changes — N/A (no UI change; symptom + video are in [Bug]: gibberish rgb escape codes appear in terminal #1238)
  • video for animation/interaction changes — N/A

Fixes the bug reported in #1238.

🤖 Generated with Claude Code


Note

Medium Risk
Changes core terminal byte processing for all sessions (history replay, live output, and PTY writes); regressions could drop legitimate escape sequences or break capability negotiation, though behavior is heavily tested.

Overview
Fixes #1238 by hardening server-side terminal I/O so capability negotiation traffic does not show up as prompt garbage on restore or loop forever.

Output path: Each PTY chunk is parsed once into two sanitized views. Scrollback drops terminal queries and responses (plus flattened residue like 2026;2$y / 1$r0m when ESC framing is gone). The live stream strips only terminal→host responses so the client emulator still receives queries it must answer. Streamed data now uses the live view; persisted history uses the scrollback view. CSI/OSC/DCS handling is expanded (DECRPM, DECRQM, DECRPSS, 8-bit C1 introducers).

Input path: stripTerminalResponsesFromInput removes browser auto-replies before process.write; writes that are only emulator responses are dropped.

Existing logs: History is sanitized on read/migrate and rewritten when dirty so older unsanitized logs self-heal.

Large new unit coverage exercises sanitization edge cases (chunk splits, ReDoS guard, focus events preserved).

Reviewed by Cursor Bugbot for commit 2fd9f6e. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Stop garbled terminal escape sequences from leaking into the terminal and persisted history

  • Adds stripTerminalResponsesFromInput in Manager.ts to filter out browser emulator auto-generated terminal responses (DECRPM, DA, DSR, OSC colour replies, DECRPSS) before writing to the PTY, skipping writes that contain only such responses.
  • Introduces sanitizeTerminalChunkDual to produce separate history and live-stream outputs in a single parse pass: history strips both queries and responses, live stream strips only responses and preserves queries.
  • Sanitizes existing persisted history logs on load (and after legacy migration), writing cleaned content back to disk if it differed.
  • Extends shouldStripCsiSequence, shouldStripOscSequence, and adds shouldStripDcsSequence to distinguish query vs. response sequences per view type.
  • Adds stripFlattenedModeReplyResidue to remove reply fragment tokens (e.g. 2026;2$y, 1$r0m) that appear echoed at prompts in both history and live output.
  • Behavioral Change: live terminal stream no longer forwards terminal responses to the PTY; history replays are cleaned before display, which may change output for sessions with pre-existing dirty logs.

Macroscope summarized 2fd9f6e.

Summary by CodeRabbit

  • Tests

    • Added comprehensive test suite for terminal escape sequence sanitization across various control sequence types and edge cases.
  • Refactor

    • Improved terminal output processing to efficiently handle both history and live stream views in a single pass, with enhanced escape sequence handling.

@coderabbitai

coderabbitai Bot commented Jun 22, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Replaces the single-view terminal escape-sequence sanitizer with sanitizeTerminalChunkDual, which parses one byte chunk and produces separate historyText and liveText outputs. Adds parameterized shouldStripCsiSequence, shouldStripOscSequence, and shouldStripDcsSequence helpers. Updates drainProcessEvents to use the dual parser and updates the public sanitizeTerminalHistoryChunk signature. Adds 134 lines of tests covering the new behavior.

Changes

Dual-view terminal sanitization

Layer / File(s) Summary
Parameterized CSI/OSC/DCS strip helpers
apps/server/src/terminal/Manager.ts
shouldStripCsiSequence gains a responsesOnly parameter for query-vs-reply discrimination; shouldStripOscSequence and shouldStripDcsSequence helpers are added with the same live-vs-history distinction.
sanitizeTerminalChunkDual dual-buffer parser
apps/server/src/terminal/Manager.ts
New sanitizeTerminalChunkDual parses one byte chunk with a shared pending-sequence boundary, maintaining historyText and liveText buffers simultaneously and routing each CSI/OSC/DCS sequence to the appropriate buffer via the strip helpers.
Public API update and runtime drain wiring
apps/server/src/terminal/Manager.ts
sanitizeTerminalHistoryChunk accepts an optional responsesOnly option and selects liveText vs historyText from the dual result. drainProcessEvents is updated to call sanitizeTerminalChunkDual, appending historyText to session history and publishing liveText as the streamed data payload.
sanitizeTerminalHistoryChunk test suite
apps/server/src/terminal/Manager.test.ts
Adds a describe("sanitizeTerminalHistoryChunk") suite covering DECRPM/DECRQM stripping, normal CSI preservation, split-sequence buffering, responsesOnly live-vs-scrollback differences, 8-bit CSI/OSC introducers, incomplete 8-bit sequence buffering, and DCS DECRQSS/DECRPSS handling.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 Hop hop through the escape bytes I go,
Two buffers now: one live, one for the show!
CSI queries hop left, replies hop right,
DCS and OSC sorted out just right.
The pending chunk waits snug in my pouch —
No replay glitch shall make the terminal grouch! 🌿

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 44.44% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main fix: preventing garbled escape sequences from leaking into the terminal, which is the core objective.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The PR description follows the required template and includes clear What Changed, Why, UI Changes, and Checklist sections.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:L 100-499 changed lines (additions + deletions). labels Jun 22, 2026
@olafura olafura force-pushed the fix/terminal-garbled-output branch from a522d32 to 91f9152 Compare June 22, 2026 17:46
macroscopeapp[bot]
macroscopeapp Bot previously approved these changes Jun 22, 2026
@macroscopeapp

macroscopeapp Bot commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

This PR introduces substantial new terminal escape sequence sanitization logic including complex regex patterns, input filtering before PTY, and dual-mode (live vs history) sanitization. While the bug fix intent is clear and well-tested, the scope of runtime behavior changes in terminal I/O handling warrants human review.

You can customize Macroscope's approvability policy. Learn more.

@olafura

olafura commented Jun 22, 2026

Copy link
Copy Markdown
Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 22, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@olafura olafura force-pushed the fix/terminal-garbled-output branch from 91f9152 to 700a3e2 Compare June 22, 2026 20:35
@macroscopeapp macroscopeapp Bot dismissed their stale review June 22, 2026 20:35

Dismissing prior approval to re-evaluate 700a3e2

Comment thread apps/server/src/terminal/Manager.ts Outdated
Comment thread apps/server/src/terminal/Manager.ts Outdated
Comment thread apps/server/src/terminal/Manager.ts
Comment thread apps/server/src/terminal/Manager.ts Outdated
@olafura olafura force-pushed the fix/terminal-garbled-output branch from 700a3e2 to 70cbad4 Compare June 22, 2026 20:56
@olafura

olafura commented Jun 22, 2026

Copy link
Copy Markdown
Author

Addressed the code-review findings (all in apps/server/src/terminal/Manager.ts):

  1. DCS DECRPSS not stripped from client input — added \x1bP[01]?$r…ST to the input filter so the browser's status-string replies are dropped before they reach the PTY, not just CSI/OSC replies.
  2. Bare DA query over-matched on input — the secondary/primary DA pattern now requires parameters (\x1b[[?>][0-9;]+c), so a user/app's bare \x1b[c / \x1b[>c query is preserved while the …;…c reply is still stripped.
  3. Flattened DECRPSS residue not stripped from history — the flattened-residue cleaner now recognizes 1$r0m-style runs (echoed status-string replies with the ESC/P/ST framing already stripped by the terminal).
  4. Residue guard skipped c-only / $r runs — the fast-path guard now also triggers on $r, so command-only and status-string residue runs aren't skipped.

Validated each fix against the real byte forms and the captured #1238 log. Added permanent tests: an input-strip suite (stripTerminalResponsesFromInput drops the auto-reply/focus flood, keeps keystrokes, arrows, Ctrl-C, CPR, and bare DA/DSR queries) and a flattened-DECRPSS history assertion. pnpm --filter t3 typecheck, the terminal suite (68 passing), and lint are green. Squashed into the single commit and rebased on upstream/main.

Comment thread apps/server/src/terminal/Manager.ts
@olafura olafura force-pushed the fix/terminal-garbled-output branch from 70cbad4 to ab220ef Compare June 22, 2026 21:27
@olafura

olafura commented Jun 22, 2026

Copy link
Copy Markdown
Author

On the latest round:

Cursor — "Flattened garble splits across chunks" (Medium). Confirmed the mechanism: stripFlattenedModeReplyResidue runs per output chunk, so a flattened reply (the ESC introducer is already gone, so the pending-escape buffer can't hold it) that a PTY read splits mid-token — 2026;2 then $y — has both halves written to history live.

I investigated a cross-chunk carry (hold back a trailing partial token, prepend to the next chunk) and validated it against the ~30 real captured terminal logs. It cleanly handles the common case but is not complete — a split before the first ; (2026 | ;2$y) still leaks, and making it complete requires a streaming residue state machine (an unbounded line buffer, since flattened replies aren't newline-delimited and progress bars redraw with \r). Shipping a partial carry adds real complexity and a new pending-state semantic for a sub-second cosmetic transient, so I've left it out.

The finding's actual persistence concern — "the same garbled scrollback can return after restore" — is already mitigated by the load-time sanitize in this PR: the split halves land contiguously in the log, and readHistory() runs a whole-buffer sanitize on load that rejoins and strips the token. Verified two ways:

  • New regression test self-heals a flattened token split across PTY chunks on the next reload: "prompt$ 2026;2" + "$y done" → written as "prompt$ 2026;2$y done" → sanitized on reload to "prompt$ done".
  • Whole-file sanitize across all ~30 real logs leaves zero …;…$y / …;rgb:… / …$r…m residue.

So the residue is transient live-only (and the input-side strip already prevents the runaway echo loop that previously made it flood en masse), and it does not survive a restart.

The three findings from the prior round (DCS DECRPSS not stripped from input, bare DA query over-matched, flattened 1$r0m + $r guard) are already addressed in the current commit ab220ef4 — the bots reviewed 700a3e2c, before that push. pnpm --filter t3 typecheck, the terminal suite (69 passing), and lint are green; rebased on upstream/main.

@olafura olafura force-pushed the fix/terminal-garbled-output branch from ab220ef to 207f31e Compare June 22, 2026 21:55
@olafura

olafura commented Jun 22, 2026

Copy link
Copy Markdown
Author

Self-review (multi-angle, max effort) of 207f31eb

Ran a 10-angle adversarial review over the sanitizer diff and applied the clear-cut fixes (folded into this commit). Posting the findings for visibility.

Fixed

Sev Finding Fix
Critical ReDoS — the (?:[0-9]+;)+rgb:[0-9a-fA-F/]+ alternative in FLATTENED_FRAGMENT/FLATTENED_REPLY_TOKEN backtracks catastrophically on program-controlled output. Measured ~40s on a 336 KB "<digits>;"-run that never reaches rgb:. It runs per output chunk on both views and on the whole history file at load, so a program printing such a run stalls the server event loop (DoS). Pinned the colour alternative to the real OSC numbers — (?:1[012];|4;[0-9]+;)rgb: — removing the unbounded run. 40s → 18ms. Added a ReDoS regression test.
High FLATTENED_REPLY_TOKEN's trailing n? swallowed the next character ("1;2$ynext""ext"), corrupting both history and live. Removed the n?.
High [01]$r[0-9;]*[a-zA-Z] greedily consumed a following digit/; run of legitimate text ("1$r0;12;34;Hello" ate through H). Length-bounded the payload to {0,8}.
High (?:[0-9]+;)+rgb: matched ordinary "<n>;rgb:…" program text (e.g. a CSS/colour dump), deleting it from the live stream. Same OSC-number pin as the ReDoS fix.
High The input filter stripped focus events (CSI I / CSI O), so a program that enabled focus reporting (DECSET ?1004 — vim, tmux) never received them. Focus events are user-action-driven and don't feed the redraw→requery loop, so stripping them was pure regression. Dropped CSI I/CSI O from INPUT_TERMINAL_RESPONSE; added a test asserting focus events are forwarded.
Low The input OSC/DCS reply patterns rejected the 8-bit ST (0x9c) terminator the output sanitizer accepts, so a 0x9c-terminated auto-reply leaked to the PTY and could re-arm the echo loop. Added \x9c to the terminator alternation (and excluded it from the DCS body class).

Re-validated against ~30 real captured terminal logs: residue still fully stripped, in bounded time. 73 unit tests pass; typecheck + lint clean.

Considered and deliberately not changed

  • Live stream runs the flattened-residue heuristic. Applying stripFlattenedModeReplyResidue to liveText is a heuristic over arbitrary program output; the fixes above shrink its false-positive surface to genuinely reply-shaped text. The deeper "strip only at the precise escape layer, since the input-side filter already breaks the loop" argument is valid, but removing the live strip would reintroduce the transient garbage this PR set out to remove. Kept it — happy to revisit if reviewers prefer the narrower approach.
  • Bracketed-paste corruption. The input filter has no ESC[200~/ESC[201~ awareness, so pasted text containing escape sequences can be edited mid-paste. Real but needs a paste-mode state machine — out of scope here.
  • Two-c-token run bridging ("1;2c 3;4c" → stripped): tightening risks under-stripping real DA-reply runs, and that literal shape in plain text is rare. Left as-is.
  • Pre-existing efficiency (O(n²) history concat, dual-buffer build for identical views, per-chunk closures): not introduced by this diff; left alone.

No CLAUDE.md/AGENTS.md convention violations found.

@olafura olafura force-pushed the fix/terminal-garbled-output branch from 207f31e to 200b323 Compare June 24, 2026 10:12

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 200b323. Configure here.

Comment thread apps/server/src/terminal/Manager.ts Outdated
Comment thread apps/server/src/terminal/Manager.ts Outdated
Comment thread apps/server/src/terminal/Manager.ts
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 pingdotgg#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 pingdotgg#1238-residue log.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@olafura olafura force-pushed the fix/terminal-garbled-output branch from 200b323 to 2fd9f6e Compare June 24, 2026 10:39
@olafura

olafura commented Jun 24, 2026

Copy link
Copy Markdown
Author

Addressed the latest review round (2fd9f6eb)

Three OSC-4 / 8-bit-C1 findings — all fixed. (One, the framed-OSC-4 mangling, was a regression introduced by my earlier flattened-residue change; thanks for catching it.)

🟡 "Framed OSC 4 reports mangled" (r3466310762) — The escape walk keeps a framed OSC 4 palette report (only OSC 10/11/12 are stripped there), and the flattened pass then deleted its inner 4;<idx>;rgb:…, leaving a broken ESC ] … ST. Added a negative lookbehind (?<!\x1b\]|\x9d) to the flattened colour pattern so it only matches unframed residue, never a colour run still inside an intact OSC frame.

🟡 "OSC 4 replies reach PTY" (r3466310747)stripTerminalResponsesFromInput now strips OSC 4 palette rgb: replies alongside OSC 10/11/12, breaking that arm of the echo loop at the source.

🟡 "Eight-bit C1 input bypasses filter" (r3466310756) — Every input pattern now accepts the 8-bit C1 introducer (\x9b/\x9d/\x90) as well as the 7-bit ESC form, and the pre-filter triggers on either — symmetric with the output walk. (Defense-in-depth: the browser emulator emits 7-bit, but the asymmetry is now closed.)

Validation: 76 unit tests pass (+4: framed-OSC-4 preserved, OSC-4 input, C1 input), typecheck + lint clean, and re-validated against the ~30 real captured logs (no residue survives, no empty ESC ] ST frames introduced). Rebased onto the latest upstream/main and force-pushed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L 100-499 changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant