From 9a4528129fb8c2599b89600885fcecf042ae643c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 24 Apr 2026 13:58:53 +0700 Subject: [PATCH] fix: suppress terminal query response leaks --- .../terminal-emulator-runtime.browser.test.ts | 23 ++++++++++++++++++- .../runtime/terminal-emulator-runtime.ts | 11 +++++---- packages/server/src/terminal/terminal.test.ts | 17 ++++++++++++++ packages/server/src/terminal/terminal.ts | 9 -------- 4 files changed, 46 insertions(+), 14 deletions(-) diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts index e56eb81e5..c5abe9acd 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts @@ -24,6 +24,7 @@ interface MountedTerminal { host: HTMLDivElement; root: HTMLDivElement; runtime: TerminalEmulatorRuntime; + inputs: string[]; sizes: TerminalSize[]; } @@ -65,9 +66,13 @@ function createTerminalHost(input: { width: number; height: number }): MountedTe document.body.appendChild(root); const sizes: TerminalSize[] = []; + const inputs: string[] = []; const runtime = new TerminalEmulatorRuntime(); runtime.setCallbacks({ callbacks: { + onInput: (data) => { + inputs.push(data); + }, onResize: (size) => { sizes.push(size); }, @@ -84,7 +89,7 @@ function createTerminalHost(input: { width: number; height: number }): MountedTe }, }); - const mounted = { host, root, runtime, sizes }; + const mounted = { host, root, runtime, inputs, sizes }; mountedTerminals.push(mounted); return mounted; } @@ -157,6 +162,22 @@ describe("terminal emulator runtime in a real browser", () => { expect(refreshCalls.at(-1)).toEqual([0, terminal.rows - 1]); }); + it("suppresses non-identity terminal query responses as PTY input", async () => { + await page.viewport(900, 600); + const mounted = createTerminalHost({ width: 720, height: 360 }); + + await waitFor({ predicate: () => mounted.sizes.length > 0 }); + + const querySequences = ["\x1b[5n", "\x1b[6n", "\x1b[?6n", "\x1b[1$p", "\x1b[?1$p"]; + + for (const sequence of querySequences) { + mounted.runtime.write({ text: sequence }); + } + await nextFrame(); + + expect(mounted.inputs).toEqual([]); + }); + it("replays snapshots without synchronously resetting the visible terminal", async () => { await page.viewport(900, 600); const mounted = createTerminalHost({ width: 720, height: 360 }); diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts index e67670c2c..c1c64a497 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts @@ -237,11 +237,14 @@ export class TerminalEmulatorRuntime { } }); - // Suppress terminal query responses — the server-side headless xterm handles these. - // Without this, xterm.js generates DA/CPR responses via onData that feed back - // to the PTY as visible text. - terminal.parser.registerCsiHandler({ final: "c" }, () => true); + // Suppress cursor/status query responses whose async browser replies can race + // with foreground app exit and feed back to the shell as visible text. DA is + // intentionally left to xterm so attached TUIs can identify the terminal. + terminal.parser.registerCsiHandler({ final: "n" }, () => true); + terminal.parser.registerCsiHandler({ prefix: "?", final: "n" }, () => true); terminal.parser.registerCsiHandler({ final: "R" }, () => true); + terminal.parser.registerCsiHandler({ intermediates: "$", final: "p" }, () => true); + terminal.parser.registerCsiHandler({ prefix: "?", intermediates: "$", final: "p" }, () => true); const restoreDocumentStyles = this.applyDocumentBoundsStyles({ root: input.root, diff --git a/packages/server/src/terminal/terminal.test.ts b/packages/server/src/terminal/terminal.test.ts index 868ce13a3..ef77e6c69 100644 --- a/packages/server/src/terminal/terminal.test.ts +++ b/packages/server/src/terminal/terminal.test.ts @@ -978,6 +978,23 @@ describe("subscribe", () => { expect(messages.length).toBe(0); }); + + it("does not answer terminal identity queries from the headless snapshot parser", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + + await waitForLines(session, ["$"]); + + session.send({ type: "input", data: "printf '\\033[c'; echo after-da\r" }); + await waitForState(session, (state) => getLines(state).join("\n").includes("after-da")); + + expect(getLines(session.getState()).join("\n")).not.toContain("62;4;22c"); + }); }); describe("stream snapshots", () => { diff --git a/packages/server/src/terminal/terminal.ts b/packages/server/src/terminal/terminal.ts index d7b9a4aa2..94e0c8905 100644 --- a/packages/server/src/terminal/terminal.ts +++ b/packages/server/src/terminal/terminal.ts @@ -657,15 +657,6 @@ export async function createTerminal(options: CreateTerminalOptions): Promise { - if (params.length === 0 || (params.length === 1 && params[0] === 0)) { - ptyProcess.write("\x1b[?62;4;22c"); - return true; - } - return false; - }); - let disposeTitleChangeSubscription: { dispose(): void } | null = null; if (!lockedTitle) { disposeTitleChangeSubscription = terminal.onTitleChange((nextTitle) => {