This repository has no description
0

Configure Feed

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

Better sanitization, handle resizing better

Nathan Herald (Mar 9, 2026, 11:15 PM +0100) c019491f 8f953cac

+361 -10
+14 -2
src/client.ts
··· 27 27 28 28 // Reset terminal modes that programs may have enabled. This prevents 29 29 // "poisoned" terminals after detach/peek (e.g., mouse tracking, hidden 30 - // cursor, bracketed paste). Does NOT clear screen content. 31 - const TERMINAL_SANITIZE = 30 + // cursor, alternate screen buffer, bracketed paste). Does NOT clear 31 + // screen content. 32 + export const TERMINAL_SANITIZE = 33 + "\x1b[?1049l" + // leave alternate screen buffer (TUI apps: vim, htop, mactop…) 34 + "\x1b[?1l" + // reset cursor keys to normal mode (DECCKM) 35 + "\x1b[?7h" + // re-enable autowrap (DECAWM) 36 + "\x1b[?6l" + // reset origin mode (DECOM) 32 37 "\x1b[?1000l" + // disable mouse click tracking 33 38 "\x1b[?1002l" + // disable mouse button-event tracking 34 39 "\x1b[?1003l" + // disable mouse any-event tracking 40 + "\x1b[?1004l" + // disable focus event reporting 35 41 "\x1b[?1006l" + // disable SGR mouse mode 36 42 "\x1b[?25h" + // show cursor 37 43 "\x1b[?2004l" + // disable bracketed paste 44 + "\x1b[4l" + // reset insert mode (IRM) to replace 45 + "\x1b[r" + // reset scroll region (DECSTBM) to full terminal 46 + "\x1b[0m" + // reset SGR attributes (colors, bold, etc.) 47 + "\x1b[0 q" + // reset cursor style to terminal default 48 + "\x1b>" + // reset application keypad mode (DECKPNM) 49 + "\x1b(B" + // reset G0 character set to ASCII 38 50 "\x1b[<u"; // pop Kitty keyboard protocol mode 39 51 40 52 export interface PeekOptions {
+22 -8
src/server.ts
··· 167 167 client.rows = size.rows; 168 168 client.cols = size.cols; 169 169 client.attachSeq = ++this.attachCounter; 170 - this.negotiateSize(); 170 + const resized = this.negotiateSize(); 171 171 172 - // Send current screen state 173 - const screen = this.serialize.serialize(); 174 - socket.write(encodeScreen(screen)); 172 + const sendScreen = () => { 173 + if (socket.destroyed) return; 174 + const screen = this.serialize.serialize(); 175 + socket.write(encodeScreen(screen)); 176 + if (this.exited) { 177 + socket.write(encodeExit(this.exitCode)); 178 + } 179 + }; 175 180 176 - // If already exited, tell them immediately 177 - if (this.exited) { 178 - socket.write(encodeExit(this.exitCode)); 181 + if (resized && !this.exited) { 182 + // The PTY was resized, which sends SIGWINCH to the process. 183 + // Wait briefly so the process can redraw before we serialize, 184 + // otherwise the client sees a transient state (e.g., cursor 185 + // clamped to the new width instead of where the TUI places it). 186 + setTimeout(sendScreen, 50); 187 + } else { 188 + sendScreen(); 179 189 } 180 190 break; 181 191 } ··· 228 238 }); 229 239 } 230 240 231 - private negotiateSize(): void { 241 + /** Resize the PTY to match the most recently attached client. 242 + * Returns true if the size actually changed. */ 243 + private negotiateSize(): boolean { 232 244 // Use the most recently attached/resized non-readonly client's size 233 245 let lastClient: Client | null = null; 234 246 for (const client of this.clients.values()) { ··· 244 256 if (rows !== this.terminal.rows || cols !== this.terminal.cols) { 245 257 this.ptyProcess.resize(cols, rows); 246 258 this.terminal.resize(cols, rows); 259 + return true; 247 260 } 248 261 } 262 + return false; 249 263 } 250 264 251 265 private broadcast(data: Buffer): void {
+88
tests/integration.test.ts
··· 3 3 import * as fs from "node:fs"; 4 4 import * as os from "node:os"; 5 5 import * as path from "node:path"; 6 + import { Terminal } from "@xterm/headless"; 6 7 import { PtyServer, type ServerOptions } from "../src/server.ts"; 7 8 import { 8 9 MessageType, ··· 419 420 peeker.destroy(); 420 421 }); 421 422 423 + it("peek captures TUI app running in alternate screen buffer", async () => { 424 + const name = uniqueName(); 425 + // Simulate a TUI app: enter alt screen, enable mouse tracking, draw content 426 + await startServer(name, "sh", [ 427 + "-c", 428 + "printf '\\033[?1049h';" + // enter alternate screen 429 + "printf '\\033[?1000h';" + // enable mouse click tracking 430 + "printf '\\033[?1003h';" + // enable mouse any-event tracking 431 + "printf '\\033[?1h';" + // enable application cursor keys 432 + "printf '\\033[H';" + // home cursor 433 + "printf '\\033[32mTUI-PEEK-TEST\\033[0m\\n';" + 434 + "printf 'Status: running\\n';" + 435 + "sleep 30", 436 + ]); 437 + 438 + await new Promise((r) => setTimeout(r, 300)); 439 + 440 + // Peek should capture the alternate screen content 441 + const peeker = await connect(name); 442 + const peekReader = new PacketReader(); 443 + peeker.write(encodePeek()); 444 + 445 + const screenPacket = await waitForType(peeker, peekReader, MessageType.SCREEN); 446 + const screen = screenPacket.payload.toString(); 447 + expect(screen).toContain("TUI-PEEK-TEST"); 448 + expect(screen).toContain("Status: running"); 449 + 450 + peeker.destroy(); 451 + }); 452 + 422 453 it("writes session metadata on creation", async () => { 423 454 const name = uniqueName(); 424 455 await startServer(name, "cat", ["-u"]); ··· 630 661 631 662 client.destroy(); 632 663 }); 664 + 665 + it("SCREEN cursor position matches process intent after resize", async () => { 666 + const name = uniqueName(); 667 + 668 + // TUI-like process: enters alt screen, positions cursor at col 60. 669 + // On SIGWINCH: redraws with cursor at col 10. 670 + // Using "sleep & wait" so bash processes the trap immediately when 671 + // SIGWINCH interrupts the wait builtin (no sleep cycle delay). 672 + await startServer( 673 + name, 674 + "bash", 675 + [ 676 + "-c", 677 + "printf '\\033[?1049h\\033[2J\\033[1;1HTitle\\033[5;60H'; " + 678 + "trap 'printf \"\\033[2J\\033[1;1HTitle\\033[5;10H\"' WINCH; " + 679 + "sleep 300 & wait; sleep 300", 680 + ], 681 + { rows: 24, cols: 80 } 682 + ); 683 + 684 + await new Promise((r) => setTimeout(r, 500)); 685 + 686 + // Attach at original size, verify cursor is at (row 5, col 60) = (4, 59) 0-indexed 687 + const client1 = await connect(name); 688 + const reader1 = new PacketReader(); 689 + client1.write(encodeAttach(24, 80)); 690 + const screen1 = await waitForType(client1, reader1, MessageType.SCREEN); 691 + 692 + const t1 = new Terminal({ rows: 24, cols: 80, allowProposedApi: true }); 693 + await new Promise<void>((r) => t1.write(screen1.payload.toString(), r)); 694 + expect(t1.buffer.active.cursorY).toBe(4); 695 + expect(t1.buffer.active.cursorX).toBe(59); 696 + t1.dispose(); 697 + 698 + client1.destroy(); 699 + await new Promise((r) => setTimeout(r, 200)); 700 + 701 + // Reconnect at a NARROWER terminal — col 60 doesn't exist in 40 cols. 702 + // The server resizes xterm-headless to 40 cols (clamping cursor to col 39), 703 + // then serializes BEFORE the process can respond to SIGWINCH. 704 + const client2 = await connect(name); 705 + const reader2 = new PacketReader(); 706 + client2.write(encodeAttach(24, 40)); 707 + const screen2 = await waitForType(client2, reader2, MessageType.SCREEN); 708 + 709 + const t2 = new Terminal({ rows: 24, cols: 40, allowProposedApi: true }); 710 + await new Promise<void>((r) => t2.write(screen2.payload.toString(), r)); 711 + 712 + // The process's SIGWINCH trap will redraw with cursor at (4, 9). 713 + // But SCREEN was serialized before the trap could fire, so the cursor 714 + // is at the clamped position (4, 39) — not where the process wants it. 715 + expect(t2.buffer.active.cursorY).toBe(4); 716 + expect(t2.buffer.active.cursorX).toBe(9); // FAILS: actual is 39 (clamped) 717 + t2.dispose(); 718 + 719 + client2.destroy(); 720 + }, 15000); 633 721 });
+189
tests/sanitize.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { Terminal } from "@xterm/headless"; 3 + import { TERMINAL_SANITIZE } from "../src/client.ts"; 4 + 5 + // Helper: write to terminal and wait for processing 6 + function write(terminal: Terminal, data: string): Promise<void> { 7 + return new Promise((resolve) => terminal.write(data, resolve)); 8 + } 9 + 10 + // Helper: read the character at a given row/col 11 + function charAt(terminal: Terminal, row: number, col: number): string { 12 + const line = terminal.buffer.active.getLine(row); 13 + if (!line) return ""; 14 + const cell = line.getCell(col); 15 + if (!cell) return ""; 16 + return cell.getChars(); 17 + } 18 + 19 + // Helper: read a range of characters from a row 20 + function textAt(terminal: Terminal, row: number, startCol: number, len: number): string { 21 + let result = ""; 22 + for (let i = 0; i < len; i++) { 23 + result += charAt(terminal, row, startCol + i); 24 + } 25 + return result; 26 + } 27 + 28 + describe("TERMINAL_SANITIZE resets poisoned terminal modes", () => { 29 + // ─── Autowrap (DECAWM) ─── 30 + // 31 + // TUI apps sometimes disable line wrapping (\x1b[?7l) so that long lines 32 + // are truncated at the right margin instead of wrapping. If not re-enabled, 33 + // the user's shell output won't wrap — text past the terminal width is lost. 34 + 35 + it("re-enables autowrap (DECAWM) after it was disabled", async () => { 36 + const t = new Terminal({ rows: 10, cols: 5, allowProposedApi: true }); 37 + await write(t, "\x1b[?7l"); // disable autowrap 38 + await write(t, TERMINAL_SANITIZE); 39 + 40 + // Write 6 characters into a 5-column terminal. 41 + // With autowrap ON: "12345" on row 0, "X" on row 1. 42 + // With autowrap OFF: "1234X" on row 0 (last char overwrites), nothing on row 1. 43 + await write(t, "12345X"); 44 + 45 + expect(charAt(t, 1, 0)).toBe("X"); 46 + t.dispose(); 47 + }); 48 + 49 + // ─── DEC Special Graphics character set ─── 50 + // 51 + // Programs that draw box-drawing lines (e.g., borders in TUI dashboards) 52 + // switch G0 to DEC Special Graphics via \x1b(0. In this character set, 53 + // ASCII letters map to line-drawing symbols (e.g., 'q' → '─', 'a' → '▒'). 54 + // If not reset to ASCII (\x1b(B), typing in the shell produces box chars. 55 + 56 + it("resets G0 character set to ASCII after DEC line drawing was enabled", async () => { 57 + const t = new Terminal({ rows: 10, cols: 20, allowProposedApi: true }); 58 + await write(t, "\x1b(0"); // switch G0 to DEC Special Graphics 59 + await write(t, TERMINAL_SANITIZE); 60 + 61 + // Write regular ASCII. If charset is still DEC line drawing: 62 + // 'a' → '▒', 'q' → '─', 'j' → '┘' 63 + // If charset was properly reset to ASCII: 64 + // 'a' → 'a', 'q' → 'q', 'j' → 'j' 65 + await write(t, "aqj"); 66 + 67 + expect(charAt(t, 0, 0)).toBe("a"); 68 + expect(charAt(t, 0, 1)).toBe("q"); 69 + expect(charAt(t, 0, 2)).toBe("j"); 70 + t.dispose(); 71 + }); 72 + 73 + // ─── Insert mode (IRM) ─── 74 + // 75 + // Insert mode (\x1b[4h) causes newly written characters to push existing 76 + // characters to the right instead of overwriting them. If left on, shell 77 + // output would insert instead of overwrite, producing garbled display. 78 + 79 + it("resets insert mode (IRM) to replace mode", async () => { 80 + const t = new Terminal({ rows: 10, cols: 20, allowProposedApi: true }); 81 + await write(t, "\x1b[4h"); // enable insert mode 82 + await write(t, TERMINAL_SANITIZE); 83 + 84 + // Write "AB", then move cursor back to column 0 and write "X". 85 + // Replace mode (default): row 0 = "XB" 86 + // Insert mode: row 0 = "XAB" 87 + await write(t, "AB"); 88 + await write(t, "\x1b[1;1H"); // cursor to row 1, col 1 (home) 89 + await write(t, "X"); 90 + 91 + expect(charAt(t, 0, 0)).toBe("X"); 92 + expect(charAt(t, 0, 1)).toBe("B"); 93 + expect(charAt(t, 0, 2)).toBe(""); // should be empty in replace mode 94 + t.dispose(); 95 + }); 96 + 97 + // ─── Origin mode (DECOM) + scroll region (DECSTBM) ─── 98 + // 99 + // Origin mode (\x1b[?6h) makes cursor positioning relative to the scroll 100 + // region instead of the full screen. Combined with a scroll region 101 + // (\x1b[<top>;<bottom>r), cursor home goes to the top of the region, not 102 + // the top of the screen. If left set, cursor addressing is wrong. 103 + 104 + it("resets origin mode and scroll region", async () => { 105 + const t = new Terminal({ rows: 10, cols: 20, allowProposedApi: true }); 106 + await write(t, "\x1b[3;8r"); // scroll region: rows 3-8 107 + await write(t, "\x1b[?6h"); // enable origin mode 108 + await write(t, TERMINAL_SANITIZE); 109 + 110 + // Move to "home" position 111 + await write(t, "\x1b[H"); 112 + await write(t, "X"); 113 + 114 + // With origin mode OFF and no scroll region: 115 + // \x1b[H goes to row 0, col 0 (absolute top-left) 116 + // With origin mode ON and scroll region 3-8: 117 + // \x1b[H goes to row 2 (0-indexed), col 0 (top of scroll region) 118 + expect(charAt(t, 0, 0)).toBe("X"); 119 + t.dispose(); 120 + }); 121 + 122 + // ─── Scroll region alone (DECSTBM) ─── 123 + // 124 + // Even without origin mode, a leftover scroll region restricts where 125 + // scrolling happens. New output at the bottom would only scroll within the 126 + // region, leaving lines above and below frozen. 127 + 128 + it("resets scroll region to full terminal", async () => { 129 + const t = new Terminal({ rows: 10, cols: 20, allowProposedApi: true }); 130 + await write(t, "\x1b[3;5r"); // restrict scrolling to rows 3-5 131 + await write(t, TERMINAL_SANITIZE); 132 + 133 + // Write "MARKER" on row 3 (1-indexed), which is inside the old scroll region. 134 + await write(t, "\x1b[3;1H"); 135 + await write(t, "MARKER"); 136 + 137 + // Move to row 5 (1-indexed) — the old scroll region's bottom row. 138 + // Write a newline: 139 + // If scroll region was reset (full terminal): cursor moves to row 6, 140 + // no scrolling occurs (not at terminal bottom), MARKER stays on row 3. 141 + // If scroll region is still 3-5: cursor is at bottom of region, newline 142 + // scrolls within the region, MARKER is pushed off the top of the region. 143 + await write(t, "\x1b[5;1H"); 144 + await write(t, "\n"); 145 + await write(t, "BELOW"); 146 + 147 + // MARKER should still be at row 2 (0-indexed) if scroll region was reset 148 + expect(textAt(t, 2, 0, 6)).toBe("MARKER"); 149 + // BELOW should be at row 4 (0-indexed = row 5 in 1-indexed, after \n moved to row 6 → 0-indexed 5) 150 + expect(textAt(t, 5, 0, 5)).toBe("BELOW"); 151 + t.dispose(); 152 + }); 153 + 154 + // ─── Application keypad mode (DECKPAM) ─── 155 + // 156 + // Application keypad mode (\x1b=) changes numpad key sequences from normal 157 + // numbers to application-mode escape sequences. If left on, pressing numpad 158 + // keys in the shell produces escape sequences instead of numbers. 159 + // Testing this behaviorally is hard, so verify TERMINAL_SANITIZE includes 160 + // the reset sequence \x1b> (DECKPNM — normal keypad mode). 161 + 162 + it("includes application keypad mode reset (DECKPNM)", () => { 163 + expect(TERMINAL_SANITIZE).toContain("\x1b>"); 164 + }); 165 + 166 + // ─── Focus event reporting ─── 167 + // 168 + // Focus reporting (\x1b[?1004h) tells the terminal to send \x1b[I (focus in) 169 + // and \x1b[O (focus out) events. If left on, switching terminal windows/tabs 170 + // produces garbage escape sequences in the shell. 171 + 172 + it("disables focus event reporting", () => { 173 + expect(TERMINAL_SANITIZE).toContain("\x1b[?1004l"); 174 + }); 175 + 176 + // ─── Cursor style ─── 177 + // 178 + // Programs like vim change cursor style (e.g., \x1b[6 q for bar cursor in 179 + // insert mode). If not reset, the user's cursor stays as a bar/underline 180 + // instead of reverting to the terminal's default style. 181 + 182 + it("resets cursor style to terminal default", () => { 183 + // \x1b[0 q or \x1b[ q resets cursor to user's default 184 + const hasReset = 185 + TERMINAL_SANITIZE.includes("\x1b[0 q") || 186 + TERMINAL_SANITIZE.includes("\x1b[ q"); 187 + expect(hasReset).toBe(true); 188 + }); 189 + });
+48
tests/screenshot.test.ts
··· 952 952 expect(ss.text).toContain("after-alt-screen"); 953 953 expect(ss.text).not.toContain("temporary-alt"); 954 954 }); 955 + 956 + it("TUI app content is captured while running in alternate screen", async () => { 957 + // Simulates what apps like mactop, htop, vim do: enter alternate screen, 958 + // enable mouse tracking, enable application cursor keys, draw content 959 + const session = await createSession("sh", [ 960 + "-c", 961 + "printf '\\033[?1049h';" + // enter alternate screen 962 + "printf '\\033[?1000h';" + // enable mouse click tracking 963 + "printf '\\033[?1003h';" + // enable mouse any-event tracking 964 + "printf '\\033[?1006h';" + // enable SGR mouse mode 965 + "printf '\\033[?1h';" + // enable application cursor keys 966 + "printf '\\033[H';" + // home cursor 967 + "printf '\\033[32mTUI-DASHBOARD\\033[0m\\n';" + // green title 968 + "printf '\\033[33mCPU: 42%%\\033[0m\\n';" + // yellow stats 969 + "printf '\\033[31mMEM: 8.2G\\033[0m\\n';" + // red stats 970 + "sleep 30", 971 + ]); 972 + 973 + const ss = await session.waitForText("TUI-DASHBOARD"); 974 + expect(ss.text).toContain("TUI-DASHBOARD"); 975 + expect(ss.text).toContain("CPU: 42%"); 976 + expect(ss.text).toContain("MEM: 8.2G"); 977 + // Colors should be preserved 978 + expect(ss.ansi).toMatch(/\x1b\[32m/); // green 979 + expect(ss.ansi).toMatch(/\x1b\[33m/); // yellow 980 + expect(ss.ansi).toMatch(/\x1b\[31m/); // red 981 + }); 982 + 983 + it("TUI app content survives detach/reattach", async () => { 984 + const session = await createSession("sh", [ 985 + "-c", 986 + "printf '\\033[?1049h';" + 987 + "printf '\\033[?1000h';" + 988 + "printf '\\033[?1003h';" + 989 + "printf '\\033[?1h';" + 990 + "printf '\\033[H';" + 991 + "printf '\\033[1;36mSYSTEM-MONITOR\\033[0m\\n';" + 992 + "printf 'Uptime: 42 days\\n';" + 993 + "sleep 30", 994 + ]); 995 + 996 + await session.waitForText("SYSTEM-MONITOR"); 997 + await session.reconnect(); 998 + 999 + const ss = await session.waitForText("SYSTEM-MONITOR"); 1000 + expect(ss.text).toContain("SYSTEM-MONITOR"); 1001 + expect(ss.text).toContain("Uptime: 42 days"); 1002 + }); 955 1003 }); 956 1004 957 1005 // ─── Tests: multiple clients ───