From b71705580ab5e694fa1b2c0298ee863d75553ed8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Mar 2026 09:49:41 -0700 Subject: [PATCH 1/2] Preserve terminal history across ANSI control sequences - sanitize PTY output before persisting transcript history - handle chunk-split control sequences and reset pending state on restart/exit - add regression tests for reopened sessions --- .../src/terminal/Layers/Manager.test.ts | 40 ++++++ apps/server/src/terminal/Layers/Manager.ts | 129 +++++++++++++++++- apps/server/src/terminal/Services/Manager.ts | 1 + 3 files changed, 168 insertions(+), 2 deletions(-) diff --git a/apps/server/src/terminal/Layers/Manager.test.ts b/apps/server/src/terminal/Layers/Manager.test.ts index 825bcbded3..2cc45138c5 100644 --- a/apps/server/src/terminal/Layers/Manager.test.ts +++ b/apps/server/src/terminal/Layers/Manager.test.ts @@ -469,6 +469,46 @@ describe("TerminalManager", () => { manager.dispose(); }); + it("persists only displayable transcript content when PTY emits ANSI control sequences", async () => { + const { manager, ptyAdapter } = makeManager(); + await manager.open(openInput()); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + process.emitData("prompt "); + process.emitData("\u001b]11;rgb:ffff/ffff/ffff\u0007"); + process.emitData("\u001b[1;1R"); + process.emitData("done\n"); + + await manager.close({ threadId: "thread-1" }); + + const reopened = await manager.open(openInput()); + expect(reopened.history).toBe("prompt done\n"); + + manager.dispose(); + }); + + it("drops chunk-split terminal control sequences from persisted history", async () => { + const { manager, ptyAdapter } = makeManager(); + await manager.open(openInput()); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + process.emitData("prompt "); + process.emitData("\u001b]11;"); + process.emitData("rgb:ffff/ffff/ffff\u0007\u001b[1;1"); + process.emitData("Rdone\n"); + + await manager.close({ threadId: "thread-1" }); + + const reopened = await manager.open(openInput()); + expect(reopened.history).toBe("prompt done\n"); + + manager.dispose(); + }); + it("deletes history file when close(deleteHistory=true)", async () => { const { manager, ptyAdapter, logsDir } = makeManager(); await manager.open(openInput()); diff --git a/apps/server/src/terminal/Layers/Manager.ts b/apps/server/src/terminal/Layers/Manager.ts index 8c71834e9e..c24e451b1b 100644 --- a/apps/server/src/terminal/Layers/Manager.ts +++ b/apps/server/src/terminal/Layers/Manager.ts @@ -254,6 +254,116 @@ function capHistory(history: string, maxLines: number): string { return hasTrailingNewline ? `${capped}\n` : capped; } +function isPrintableTerminalCharacter(codePoint: number): boolean { + return codePoint >= 0x20 && codePoint !== 0x7f; +} + +function isCsiFinalByte(codePoint: number): boolean { + return codePoint >= 0x40 && codePoint <= 0x7e; +} + +function findStringTerminatorIndex(input: string, start: number): number | null { + for (let index = start; index < input.length; index += 1) { + const codePoint = input.charCodeAt(index); + if (codePoint === 0x07 || codePoint === 0x9c) { + return index + 1; + } + if (codePoint === 0x1b && input.charCodeAt(index + 1) === 0x5c) { + return index + 2; + } + } + return null; +} + +function sanitizeTerminalHistoryChunk( + pendingControlSequence: string, + data: string, +): { visibleText: string; pendingControlSequence: string } { + const input = `${pendingControlSequence}${data}`; + let visibleText = ""; + let index = 0; + + while (index < input.length) { + const codePoint = input.charCodeAt(index); + + if (codePoint === 0x1b) { + const nextCodePoint = input.charCodeAt(index + 1); + if (Number.isNaN(nextCodePoint)) { + return { visibleText, pendingControlSequence: input.slice(index) }; + } + + if (nextCodePoint === 0x5b) { + let cursor = index + 2; + while (cursor < input.length) { + if (isCsiFinalByte(input.charCodeAt(cursor))) { + index = cursor + 1; + break; + } + cursor += 1; + } + if (cursor >= input.length) { + return { visibleText, pendingControlSequence: input.slice(index) }; + } + continue; + } + + if ( + nextCodePoint === 0x5d || + nextCodePoint === 0x50 || + nextCodePoint === 0x5e || + nextCodePoint === 0x5f + ) { + const terminatorIndex = findStringTerminatorIndex(input, index + 2); + if (terminatorIndex === null) { + return { visibleText, pendingControlSequence: input.slice(index) }; + } + index = terminatorIndex; + continue; + } + + index += 2; + continue; + } + + if (codePoint === 0x9b) { + let cursor = index + 1; + while (cursor < input.length) { + if (isCsiFinalByte(input.charCodeAt(cursor))) { + index = cursor + 1; + break; + } + cursor += 1; + } + if (cursor >= input.length) { + return { visibleText, pendingControlSequence: input.slice(index) }; + } + continue; + } + + if (codePoint === 0x9d || codePoint === 0x90 || codePoint === 0x9e || codePoint === 0x9f) { + const terminatorIndex = findStringTerminatorIndex(input, index + 1); + if (terminatorIndex === null) { + return { visibleText, pendingControlSequence: input.slice(index) }; + } + index = terminatorIndex; + continue; + } + + if ( + codePoint === 0x0a || + codePoint === 0x0d || + codePoint === 0x09 || + isPrintableTerminalCharacter(codePoint) + ) { + visibleText += input[index]; + } + + index += 1; + } + + return { visibleText, pendingControlSequence: "" }; +} + function legacySafeThreadId(threadId: string): string { return threadId.replace(/[^a-zA-Z0-9._-]/g, "_"); } @@ -378,6 +488,7 @@ export class TerminalManagerRuntime extends EventEmitter status: "starting", pid: null, history, + pendingHistoryControlSequence: "", exitCode: null, exitSignal: null, updatedAt: new Date().toISOString(), @@ -407,10 +518,12 @@ export class TerminalManagerRuntime extends EventEmitter existing.cwd = input.cwd; existing.runtimeEnv = nextRuntimeEnv; existing.history = ""; + existing.pendingHistoryControlSequence = ""; await this.persistHistory(existing.threadId, existing.terminalId, existing.history); } else if (existing.status === "exited" || existing.status === "error") { existing.runtimeEnv = nextRuntimeEnv; existing.history = ""; + existing.pendingHistoryControlSequence = ""; await this.persistHistory(existing.threadId, existing.terminalId, existing.history); } else if (currentRuntimeEnv !== nextRuntimeEnv) { existing.runtimeEnv = nextRuntimeEnv; @@ -469,6 +582,7 @@ export class TerminalManagerRuntime extends EventEmitter await this.runWithThreadLock(input.threadId, async () => { const session = this.requireSession(input.threadId, input.terminalId); session.history = ""; + session.pendingHistoryControlSequence = ""; session.updatedAt = new Date().toISOString(); await this.persistHistory(input.threadId, input.terminalId, session.history); this.emitEvent({ @@ -497,6 +611,7 @@ export class TerminalManagerRuntime extends EventEmitter status: "starting", pid: null, history: "", + pendingHistoryControlSequence: "", exitCode: null, exitSignal: null, updatedAt: new Date().toISOString(), @@ -520,6 +635,7 @@ export class TerminalManagerRuntime extends EventEmitter const rows = input.rows ?? session.rows; session.history = ""; + session.pendingHistoryControlSequence = ""; await this.persistHistory(input.threadId, input.terminalId, session.history); await this.startSession(session, { ...input, cols, rows }, "restarted"); return this.snapshot(session); @@ -694,9 +810,16 @@ export class TerminalManagerRuntime extends EventEmitter } private onProcessData(session: TerminalSessionState, data: string): void { - session.history = capHistory(`${session.history}${data}`, this.historyLineLimit); + const sanitized = sanitizeTerminalHistoryChunk(session.pendingHistoryControlSequence, data); + session.pendingHistoryControlSequence = sanitized.pendingControlSequence; + if (sanitized.visibleText.length > 0) { + session.history = capHistory( + `${session.history}${sanitized.visibleText}`, + this.historyLineLimit, + ); + this.queuePersist(session.threadId, session.terminalId, session.history); + } session.updatedAt = new Date().toISOString(); - this.queuePersist(session.threadId, session.terminalId, session.history); this.emitEvent({ type: "output", threadId: session.threadId, @@ -713,6 +836,7 @@ export class TerminalManagerRuntime extends EventEmitter session.pid = null; session.hasRunningSubprocess = false; session.status = "exited"; + session.pendingHistoryControlSequence = ""; session.exitCode = Number.isInteger(event.exitCode) ? event.exitCode : null; session.exitSignal = Number.isInteger(event.signal) ? event.signal : null; session.updatedAt = new Date().toISOString(); @@ -736,6 +860,7 @@ export class TerminalManagerRuntime extends EventEmitter session.pid = null; session.hasRunningSubprocess = false; session.status = "exited"; + session.pendingHistoryControlSequence = ""; session.updatedAt = new Date().toISOString(); this.killProcessWithEscalation(process, session.threadId, session.terminalId); this.evictInactiveSessionsIfNeeded(); diff --git a/apps/server/src/terminal/Services/Manager.ts b/apps/server/src/terminal/Services/Manager.ts index 8d8398c7ad..c2539da4b6 100644 --- a/apps/server/src/terminal/Services/Manager.ts +++ b/apps/server/src/terminal/Services/Manager.ts @@ -32,6 +32,7 @@ export interface TerminalSessionState { status: TerminalSessionStatus; pid: number | null; history: string; + pendingHistoryControlSequence: string; exitCode: number | null; exitSignal: number | null; updatedAt: string; From 56063fa3183ae2257f862fc3e30af4341b4174ca Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Mar 2026 10:01:22 -0700 Subject: [PATCH 2/2] Preserve safe terminal escapes in reopened history - keep visible ANSI styling and screen clears in persisted terminal history - strip replay-unsafe query/reply sequences and avoid chunk-boundary byte leakage - add regression coverage for split escape sequences --- .../src/terminal/Layers/Manager.test.ts | 53 ++++++++++- apps/server/src/terminal/Layers/Manager.ts | 92 ++++++++++++++++--- 2 files changed, 126 insertions(+), 19 deletions(-) diff --git a/apps/server/src/terminal/Layers/Manager.test.ts b/apps/server/src/terminal/Layers/Manager.test.ts index 2cc45138c5..53ee7d74f1 100644 --- a/apps/server/src/terminal/Layers/Manager.test.ts +++ b/apps/server/src/terminal/Layers/Manager.test.ts @@ -469,7 +469,7 @@ describe("TerminalManager", () => { manager.dispose(); }); - it("persists only displayable transcript content when PTY emits ANSI control sequences", async () => { + it("strips replay-unsafe terminal query and reply sequences from persisted history", async () => { const { manager, ptyAdapter } = makeManager(); await manager.open(openInput()); const process = ptyAdapter.processes[0]; @@ -477,6 +477,7 @@ describe("TerminalManager", () => { if (!process) return; process.emitData("prompt "); + process.emitData("\u001b[32mok\u001b[0m "); process.emitData("\u001b]11;rgb:ffff/ffff/ffff\u0007"); process.emitData("\u001b[1;1R"); process.emitData("done\n"); @@ -484,27 +485,69 @@ describe("TerminalManager", () => { await manager.close({ threadId: "thread-1" }); const reopened = await manager.open(openInput()); - expect(reopened.history).toBe("prompt done\n"); + expect(reopened.history).toBe("prompt \u001b[32mok\u001b[0m done\n"); manager.dispose(); }); - it("drops chunk-split terminal control sequences from persisted history", async () => { + it("preserves clear and style control sequences while dropping chunk-split query traffic", async () => { const { manager, ptyAdapter } = makeManager(); await manager.open(openInput()); const process = ptyAdapter.processes[0]; expect(process).toBeDefined(); if (!process) return; + process.emitData("before clear\n"); + process.emitData("\u001b[H\u001b[2J"); process.emitData("prompt "); process.emitData("\u001b]11;"); process.emitData("rgb:ffff/ffff/ffff\u0007\u001b[1;1"); - process.emitData("Rdone\n"); + process.emitData("R\u001b[36mdone\u001b[0m\n"); await manager.close({ threadId: "thread-1" }); const reopened = await manager.open(openInput()); - expect(reopened.history).toBe("prompt done\n"); + expect(reopened.history).toBe( + "before clear\n\u001b[H\u001b[2Jprompt \u001b[36mdone\u001b[0m\n", + ); + + manager.dispose(); + }); + + it("does not leak final bytes from ESC sequences with intermediate bytes", async () => { + const { manager, ptyAdapter } = makeManager(); + await manager.open(openInput()); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + process.emitData("before "); + process.emitData("\u001b(B"); + process.emitData("after\n"); + + await manager.close({ threadId: "thread-1" }); + + const reopened = await manager.open(openInput()); + expect(reopened.history).toBe("before \u001b(Bafter\n"); + + manager.dispose(); + }); + + it("preserves chunk-split ESC sequences with intermediate bytes without leaking final bytes", async () => { + const { manager, ptyAdapter } = makeManager(); + await manager.open(openInput()); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + process.emitData("before "); + process.emitData("\u001b("); + process.emitData("Bafter\n"); + + await manager.close({ threadId: "thread-1" }); + + const reopened = await manager.open(openInput()); + expect(reopened.history).toBe("before \u001b(Bafter\n"); manager.dispose(); }); diff --git a/apps/server/src/terminal/Layers/Manager.ts b/apps/server/src/terminal/Layers/Manager.ts index c24e451b1b..b5085220c2 100644 --- a/apps/server/src/terminal/Layers/Manager.ts +++ b/apps/server/src/terminal/Layers/Manager.ts @@ -254,14 +254,38 @@ function capHistory(history: string, maxLines: number): string { return hasTrailingNewline ? `${capped}\n` : capped; } -function isPrintableTerminalCharacter(codePoint: number): boolean { - return codePoint >= 0x20 && codePoint !== 0x7f; -} - function isCsiFinalByte(codePoint: number): boolean { return codePoint >= 0x40 && codePoint <= 0x7e; } +function shouldStripCsiSequence(body: string, finalByte: string): boolean { + if (finalByte === "n") { + return true; + } + if (finalByte === "R" && /^[0-9;?]*$/.test(body)) { + return true; + } + if (finalByte === "c" && /^[>0-9;?]*$/.test(body)) { + return true; + } + return false; +} + +function shouldStripOscSequence(content: string): boolean { + return /^(10|11|12);(?:\?|rgb:)/.test(content); +} + +function stripStringTerminator(value: string): string { + if (value.endsWith("\u001b\\")) { + return value.slice(0, -2); + } + const lastCharacter = value.at(-1); + if (lastCharacter === "\u0007" || lastCharacter === "\u009c") { + return value.slice(0, -1); + } + return value; +} + function findStringTerminatorIndex(input: string, start: number): number | null { for (let index = start; index < input.length; index += 1) { const codePoint = input.charCodeAt(index); @@ -275,6 +299,25 @@ function findStringTerminatorIndex(input: string, start: number): number | null return null; } +function isEscapeIntermediateByte(codePoint: number): boolean { + return codePoint >= 0x20 && codePoint <= 0x2f; +} + +function isEscapeFinalByte(codePoint: number): boolean { + return codePoint >= 0x30 && codePoint <= 0x7e; +} + +function findEscapeSequenceEndIndex(input: string, start: number): number | null { + let cursor = start; + while (cursor < input.length && isEscapeIntermediateByte(input.charCodeAt(cursor))) { + cursor += 1; + } + if (cursor >= input.length) { + return null; + } + return isEscapeFinalByte(input.charCodeAt(cursor)) ? cursor + 1 : start + 1; +} + function sanitizeTerminalHistoryChunk( pendingControlSequence: string, data: string, @@ -283,6 +326,10 @@ function sanitizeTerminalHistoryChunk( let visibleText = ""; let index = 0; + const append = (value: string) => { + visibleText += value; + }; + while (index < input.length) { const codePoint = input.charCodeAt(index); @@ -296,6 +343,11 @@ function sanitizeTerminalHistoryChunk( let cursor = index + 2; while (cursor < input.length) { if (isCsiFinalByte(input.charCodeAt(cursor))) { + const sequence = input.slice(index, cursor + 1); + const body = input.slice(index + 2, cursor); + if (!shouldStripCsiSequence(body, input[cursor] ?? "")) { + append(sequence); + } index = cursor + 1; break; } @@ -317,11 +369,21 @@ function sanitizeTerminalHistoryChunk( if (terminatorIndex === null) { return { visibleText, pendingControlSequence: input.slice(index) }; } + const sequence = input.slice(index, terminatorIndex); + const content = stripStringTerminator(input.slice(index + 2, terminatorIndex)); + if (nextCodePoint !== 0x5d || !shouldStripOscSequence(content)) { + append(sequence); + } index = terminatorIndex; continue; } - index += 2; + const escapeSequenceEndIndex = findEscapeSequenceEndIndex(input, index + 1); + if (escapeSequenceEndIndex === null) { + return { visibleText, pendingControlSequence: input.slice(index) }; + } + append(input.slice(index, escapeSequenceEndIndex)); + index = escapeSequenceEndIndex; continue; } @@ -329,6 +391,11 @@ function sanitizeTerminalHistoryChunk( let cursor = index + 1; while (cursor < input.length) { if (isCsiFinalByte(input.charCodeAt(cursor))) { + const sequence = input.slice(index, cursor + 1); + const body = input.slice(index + 1, cursor); + if (!shouldStripCsiSequence(body, input[cursor] ?? "")) { + append(sequence); + } index = cursor + 1; break; } @@ -345,19 +412,16 @@ function sanitizeTerminalHistoryChunk( if (terminatorIndex === null) { return { visibleText, pendingControlSequence: input.slice(index) }; } + const sequence = input.slice(index, terminatorIndex); + const content = stripStringTerminator(input.slice(index + 1, terminatorIndex)); + if (codePoint !== 0x9d || !shouldStripOscSequence(content)) { + append(sequence); + } index = terminatorIndex; continue; } - if ( - codePoint === 0x0a || - codePoint === 0x0d || - codePoint === 0x09 || - isPrintableTerminalCharacter(codePoint) - ) { - visibleText += input[index]; - } - + append(input[index] ?? ""); index += 1; }