This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

Fix terminal query leak — respond to and strip OSC 10/11/4, DA2, DSR, XTVERSION from client broadcast

Nathan Herald (Apr 15, 2026, 3:18 PM +0200) e6cbe669 e2a6238d

+101 -5
+1 -1
CHANGELOG.md
··· 3 3 ## Unreleased 4 4 5 5 ### Fixes 6 - - Respond to terminal queries that expect a response on stdin — prevents garbage input in programs like `less` and `git log`: OSC 10/11 (foreground/background color), OSC 4 (palette color), DA2 (secondary device attributes), DSR (cursor position), XTVERSION 6 + - Fix garbage characters in `less`/`git log`: respond to terminal queries (OSC 10/11/4, DA2, DSR, XTVERSION) and strip them from client broadcast so the client's terminal doesn't respond with duplicate input 7 7 8 8 ### pty exec 9 9 - Add `pty exec -- <command> [args...]` to replace the current session's command from inside the session
+26 -4
src/server.ts
··· 103 103 return undefined; 104 104 } 105 105 106 + /** Strip terminal query sequences that should not be forwarded to clients. 107 + * Exported for unit testing. */ 108 + export function stripTerminalQueries(data: string): string { 109 + return data 110 + .replace(/\x1b\]1[01];\?\x07/g, "") // OSC 10/11 with BEL 111 + .replace(/\x1b\]1[01];\?\x1b\\/g, "") // OSC 10/11 with ST 112 + .replace(/\x1b\]4;\d+;\?\x07/g, "") // OSC 4 with BEL 113 + .replace(/\x1b\]4;\d+;\?\x1b\\/g, "") // OSC 4 with ST 114 + .replace(/\x1b\[c/g, "") // DA1 115 + .replace(/\x1b\[>c/g, "") // DA2 116 + .replace(/\x1b\[6n/g, "") // DSR cursor position 117 + .replace(/\x1b\[>0q/g, ""); // XTVERSION 118 + } 119 + 106 120 export class PtyServer { 107 121 private terminal: Terminal; 108 122 private serialize: SerializeAddon; ··· 250 264 // intercept common queries and respond directly to the PTY process. 251 265 252 266 // OSC 10: foreground color query (less, vim) 267 + // Return true to consume the sequence so it doesn't leak to clients. 253 268 this.terminal.parser.registerOscHandler(10, (data: string) => { 254 269 if (data === "?") { 255 270 this.ptyProcess.write("\x1b]10;rgb:c0c0/c0c0/c0c0\x1b\\"); 271 + return true; // consume — don't pass to client 256 272 } 257 273 return false; 258 274 }); ··· 260 276 this.terminal.parser.registerOscHandler(11, (data: string) => { 261 277 if (data === "?") { 262 278 this.ptyProcess.write("\x1b]11;rgb:0000/0000/0000\x1b\\"); 279 + return true; 263 280 } 264 281 return false; 265 282 }); 266 283 // OSC 4: palette color query (vim, emacs) 267 284 this.terminal.parser.registerOscHandler(4, (data: string) => { 268 285 if (data.includes("?")) { 269 - // Format: "N;?" — respond with color N's value 270 286 const idx = parseInt(data, 10); 271 287 if (!isNaN(idx)) { 272 - // Respond with xterm-256color default for this index 273 288 this.ptyProcess.write(`\x1b]4;${idx};rgb:0000/0000/0000\x1b\\`); 274 289 } 290 + return true; 275 291 } 276 292 return false; 277 293 }); ··· 340 356 throw err; 341 357 } 342 358 343 - // Feed PTY output into xterm-headless and broadcast to clients 359 + // Feed PTY output into xterm-headless and broadcast to clients. 360 + // Query sequences (OSC 10/11, DA1, etc.) are intercepted by parser 361 + // handlers above and must NOT be forwarded to clients — otherwise the 362 + // client's terminal responds and its response appears as garbage input. 344 363 this.ptyProcess.onData((data: string) => { 345 364 this.terminal.write(data); 346 - this.broadcast(encodeData(data)); 365 + const cleaned = stripTerminalQueries(data); 366 + if (cleaned.length > 0) { 367 + this.broadcast(encodeData(cleaned)); 368 + } 347 369 }); 348 370 349 371 this.ptyProcess.onExit(({ exitCode }) => {
+74
tests/terminal-queries.test.ts
··· 2 2 import * as fs from "node:fs"; 3 3 import * as os from "node:os"; 4 4 import { Session } from "../src/testing/index.ts"; 5 + import { stripTerminalQueries } from "../src/server.ts"; 5 6 6 7 // Isolate from real session directory 7 8 const testSessionDir = fs.mkdtempSync(os.tmpdir() + "/pty-queries-"); ··· 12 13 try { fs.rmSync(testSessionDir, { recursive: true, force: true }); } catch {} 13 14 resolve(undefined); 14 15 }, 500); 16 + }); 17 + }); 18 + 19 + describe("stripTerminalQueries", () => { 20 + it("strips OSC 10 query with BEL terminator", () => { 21 + expect(stripTerminalQueries("\x1b]10;?\x07")).toBe(""); 22 + }); 23 + 24 + it("strips OSC 10 query with ST terminator", () => { 25 + expect(stripTerminalQueries("\x1b]10;?\x1b\\")).toBe(""); 26 + }); 27 + 28 + it("strips OSC 11 query with BEL terminator", () => { 29 + expect(stripTerminalQueries("\x1b]11;?\x07")).toBe(""); 30 + }); 31 + 32 + it("strips OSC 11 query with ST terminator", () => { 33 + expect(stripTerminalQueries("\x1b]11;?\x1b\\")).toBe(""); 34 + }); 35 + 36 + it("strips OSC 4 palette query with BEL", () => { 37 + expect(stripTerminalQueries("\x1b]4;7;?\x07")).toBe(""); 38 + expect(stripTerminalQueries("\x1b]4;255;?\x07")).toBe(""); 39 + }); 40 + 41 + it("strips OSC 4 palette query with ST", () => { 42 + expect(stripTerminalQueries("\x1b]4;0;?\x1b\\")).toBe(""); 43 + }); 44 + 45 + it("strips DA1 query", () => { 46 + expect(stripTerminalQueries("\x1b[c")).toBe(""); 47 + }); 48 + 49 + it("strips DA2 query", () => { 50 + expect(stripTerminalQueries("\x1b[>c")).toBe(""); 51 + }); 52 + 53 + it("strips DSR cursor position query", () => { 54 + expect(stripTerminalQueries("\x1b[6n")).toBe(""); 55 + }); 56 + 57 + it("strips XTVERSION query", () => { 58 + expect(stripTerminalQueries("\x1b[>0q")).toBe(""); 59 + }); 60 + 61 + it("preserves normal text", () => { 62 + expect(stripTerminalQueries("hello world")).toBe("hello world"); 63 + }); 64 + 65 + it("preserves normal ANSI sequences", () => { 66 + const ansi = "\x1b[1;31mred bold\x1b[0m"; 67 + expect(stripTerminalQueries(ansi)).toBe(ansi); 68 + }); 69 + 70 + it("strips queries embedded in normal output", () => { 71 + expect(stripTerminalQueries("before\x1b]11;?\x07after")).toBe("beforeafter"); 72 + }); 73 + 74 + it("strips multiple queries in one chunk", () => { 75 + const data = "\x1b]10;?\x07\x1b]11;?\x07\x1b[c"; 76 + expect(stripTerminalQueries(data)).toBe(""); 77 + }); 78 + 79 + it("preserves OSC sequences that are not queries", () => { 80 + // OSC 0 (set title) should pass through 81 + const title = "\x1b]0;my title\x07"; 82 + expect(stripTerminalQueries(title)).toBe(title); 83 + }); 84 + 85 + it("does not strip OSC 10/11 set commands (only queries)", () => { 86 + // Setting foreground color (not a query — no "?") 87 + const set = "\x1b]10;rgb:ffff/0000/0000\x07"; 88 + expect(stripTerminalQueries(set)).toBe(set); 15 89 }); 16 90 }); 17 91