Skip to content

Commit 529315b

Browse files
Preserve terminal history across ANSI control sequences (#1367)
1 parent 0a503d0 commit 529315b

3 files changed

Lines changed: 275 additions & 2 deletions

File tree

apps/server/src/terminal/Layers/Manager.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -469,6 +469,89 @@ describe("TerminalManager", () => {
469469
manager.dispose();
470470
});
471471

472+
it("strips replay-unsafe terminal query and reply sequences from persisted history", async () => {
473+
const { manager, ptyAdapter } = makeManager();
474+
await manager.open(openInput());
475+
const process = ptyAdapter.processes[0];
476+
expect(process).toBeDefined();
477+
if (!process) return;
478+
479+
process.emitData("prompt ");
480+
process.emitData("\u001b[32mok\u001b[0m ");
481+
process.emitData("\u001b]11;rgb:ffff/ffff/ffff\u0007");
482+
process.emitData("\u001b[1;1R");
483+
process.emitData("done\n");
484+
485+
await manager.close({ threadId: "thread-1" });
486+
487+
const reopened = await manager.open(openInput());
488+
expect(reopened.history).toBe("prompt \u001b[32mok\u001b[0m done\n");
489+
490+
manager.dispose();
491+
});
492+
493+
it("preserves clear and style control sequences while dropping chunk-split query traffic", async () => {
494+
const { manager, ptyAdapter } = makeManager();
495+
await manager.open(openInput());
496+
const process = ptyAdapter.processes[0];
497+
expect(process).toBeDefined();
498+
if (!process) return;
499+
500+
process.emitData("before clear\n");
501+
process.emitData("\u001b[H\u001b[2J");
502+
process.emitData("prompt ");
503+
process.emitData("\u001b]11;");
504+
process.emitData("rgb:ffff/ffff/ffff\u0007\u001b[1;1");
505+
process.emitData("R\u001b[36mdone\u001b[0m\n");
506+
507+
await manager.close({ threadId: "thread-1" });
508+
509+
const reopened = await manager.open(openInput());
510+
expect(reopened.history).toBe(
511+
"before clear\n\u001b[H\u001b[2Jprompt \u001b[36mdone\u001b[0m\n",
512+
);
513+
514+
manager.dispose();
515+
});
516+
517+
it("does not leak final bytes from ESC sequences with intermediate bytes", async () => {
518+
const { manager, ptyAdapter } = makeManager();
519+
await manager.open(openInput());
520+
const process = ptyAdapter.processes[0];
521+
expect(process).toBeDefined();
522+
if (!process) return;
523+
524+
process.emitData("before ");
525+
process.emitData("\u001b(B");
526+
process.emitData("after\n");
527+
528+
await manager.close({ threadId: "thread-1" });
529+
530+
const reopened = await manager.open(openInput());
531+
expect(reopened.history).toBe("before \u001b(Bafter\n");
532+
533+
manager.dispose();
534+
});
535+
536+
it("preserves chunk-split ESC sequences with intermediate bytes without leaking final bytes", async () => {
537+
const { manager, ptyAdapter } = makeManager();
538+
await manager.open(openInput());
539+
const process = ptyAdapter.processes[0];
540+
expect(process).toBeDefined();
541+
if (!process) return;
542+
543+
process.emitData("before ");
544+
process.emitData("\u001b(");
545+
process.emitData("Bafter\n");
546+
547+
await manager.close({ threadId: "thread-1" });
548+
549+
const reopened = await manager.open(openInput());
550+
expect(reopened.history).toBe("before \u001b(Bafter\n");
551+
552+
manager.dispose();
553+
});
554+
472555
it("deletes history file when close(deleteHistory=true)", async () => {
473556
const { manager, ptyAdapter, logsDir } = makeManager();
474557
await manager.open(openInput());

apps/server/src/terminal/Layers/Manager.ts

Lines changed: 191 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,180 @@ function capHistory(history: string, maxLines: number): string {
254254
return hasTrailingNewline ? `${capped}\n` : capped;
255255
}
256256

257+
function isCsiFinalByte(codePoint: number): boolean {
258+
return codePoint >= 0x40 && codePoint <= 0x7e;
259+
}
260+
261+
function shouldStripCsiSequence(body: string, finalByte: string): boolean {
262+
if (finalByte === "n") {
263+
return true;
264+
}
265+
if (finalByte === "R" && /^[0-9;?]*$/.test(body)) {
266+
return true;
267+
}
268+
if (finalByte === "c" && /^[>0-9;?]*$/.test(body)) {
269+
return true;
270+
}
271+
return false;
272+
}
273+
274+
function shouldStripOscSequence(content: string): boolean {
275+
return /^(10|11|12);(?:\?|rgb:)/.test(content);
276+
}
277+
278+
function stripStringTerminator(value: string): string {
279+
if (value.endsWith("\u001b\\")) {
280+
return value.slice(0, -2);
281+
}
282+
const lastCharacter = value.at(-1);
283+
if (lastCharacter === "\u0007" || lastCharacter === "\u009c") {
284+
return value.slice(0, -1);
285+
}
286+
return value;
287+
}
288+
289+
function findStringTerminatorIndex(input: string, start: number): number | null {
290+
for (let index = start; index < input.length; index += 1) {
291+
const codePoint = input.charCodeAt(index);
292+
if (codePoint === 0x07 || codePoint === 0x9c) {
293+
return index + 1;
294+
}
295+
if (codePoint === 0x1b && input.charCodeAt(index + 1) === 0x5c) {
296+
return index + 2;
297+
}
298+
}
299+
return null;
300+
}
301+
302+
function isEscapeIntermediateByte(codePoint: number): boolean {
303+
return codePoint >= 0x20 && codePoint <= 0x2f;
304+
}
305+
306+
function isEscapeFinalByte(codePoint: number): boolean {
307+
return codePoint >= 0x30 && codePoint <= 0x7e;
308+
}
309+
310+
function findEscapeSequenceEndIndex(input: string, start: number): number | null {
311+
let cursor = start;
312+
while (cursor < input.length && isEscapeIntermediateByte(input.charCodeAt(cursor))) {
313+
cursor += 1;
314+
}
315+
if (cursor >= input.length) {
316+
return null;
317+
}
318+
return isEscapeFinalByte(input.charCodeAt(cursor)) ? cursor + 1 : start + 1;
319+
}
320+
321+
function sanitizeTerminalHistoryChunk(
322+
pendingControlSequence: string,
323+
data: string,
324+
): { visibleText: string; pendingControlSequence: string } {
325+
const input = `${pendingControlSequence}${data}`;
326+
let visibleText = "";
327+
let index = 0;
328+
329+
const append = (value: string) => {
330+
visibleText += value;
331+
};
332+
333+
while (index < input.length) {
334+
const codePoint = input.charCodeAt(index);
335+
336+
if (codePoint === 0x1b) {
337+
const nextCodePoint = input.charCodeAt(index + 1);
338+
if (Number.isNaN(nextCodePoint)) {
339+
return { visibleText, pendingControlSequence: input.slice(index) };
340+
}
341+
342+
if (nextCodePoint === 0x5b) {
343+
let cursor = index + 2;
344+
while (cursor < input.length) {
345+
if (isCsiFinalByte(input.charCodeAt(cursor))) {
346+
const sequence = input.slice(index, cursor + 1);
347+
const body = input.slice(index + 2, cursor);
348+
if (!shouldStripCsiSequence(body, input[cursor] ?? "")) {
349+
append(sequence);
350+
}
351+
index = cursor + 1;
352+
break;
353+
}
354+
cursor += 1;
355+
}
356+
if (cursor >= input.length) {
357+
return { visibleText, pendingControlSequence: input.slice(index) };
358+
}
359+
continue;
360+
}
361+
362+
if (
363+
nextCodePoint === 0x5d ||
364+
nextCodePoint === 0x50 ||
365+
nextCodePoint === 0x5e ||
366+
nextCodePoint === 0x5f
367+
) {
368+
const terminatorIndex = findStringTerminatorIndex(input, index + 2);
369+
if (terminatorIndex === null) {
370+
return { visibleText, pendingControlSequence: input.slice(index) };
371+
}
372+
const sequence = input.slice(index, terminatorIndex);
373+
const content = stripStringTerminator(input.slice(index + 2, terminatorIndex));
374+
if (nextCodePoint !== 0x5d || !shouldStripOscSequence(content)) {
375+
append(sequence);
376+
}
377+
index = terminatorIndex;
378+
continue;
379+
}
380+
381+
const escapeSequenceEndIndex = findEscapeSequenceEndIndex(input, index + 1);
382+
if (escapeSequenceEndIndex === null) {
383+
return { visibleText, pendingControlSequence: input.slice(index) };
384+
}
385+
append(input.slice(index, escapeSequenceEndIndex));
386+
index = escapeSequenceEndIndex;
387+
continue;
388+
}
389+
390+
if (codePoint === 0x9b) {
391+
let cursor = index + 1;
392+
while (cursor < input.length) {
393+
if (isCsiFinalByte(input.charCodeAt(cursor))) {
394+
const sequence = input.slice(index, cursor + 1);
395+
const body = input.slice(index + 1, cursor);
396+
if (!shouldStripCsiSequence(body, input[cursor] ?? "")) {
397+
append(sequence);
398+
}
399+
index = cursor + 1;
400+
break;
401+
}
402+
cursor += 1;
403+
}
404+
if (cursor >= input.length) {
405+
return { visibleText, pendingControlSequence: input.slice(index) };
406+
}
407+
continue;
408+
}
409+
410+
if (codePoint === 0x9d || codePoint === 0x90 || codePoint === 0x9e || codePoint === 0x9f) {
411+
const terminatorIndex = findStringTerminatorIndex(input, index + 1);
412+
if (terminatorIndex === null) {
413+
return { visibleText, pendingControlSequence: input.slice(index) };
414+
}
415+
const sequence = input.slice(index, terminatorIndex);
416+
const content = stripStringTerminator(input.slice(index + 1, terminatorIndex));
417+
if (codePoint !== 0x9d || !shouldStripOscSequence(content)) {
418+
append(sequence);
419+
}
420+
index = terminatorIndex;
421+
continue;
422+
}
423+
424+
append(input[index] ?? "");
425+
index += 1;
426+
}
427+
428+
return { visibleText, pendingControlSequence: "" };
429+
}
430+
257431
function legacySafeThreadId(threadId: string): string {
258432
return threadId.replace(/[^a-zA-Z0-9._-]/g, "_");
259433
}
@@ -378,6 +552,7 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents>
378552
status: "starting",
379553
pid: null,
380554
history,
555+
pendingHistoryControlSequence: "",
381556
exitCode: null,
382557
exitSignal: null,
383558
updatedAt: new Date().toISOString(),
@@ -407,10 +582,12 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents>
407582
existing.cwd = input.cwd;
408583
existing.runtimeEnv = nextRuntimeEnv;
409584
existing.history = "";
585+
existing.pendingHistoryControlSequence = "";
410586
await this.persistHistory(existing.threadId, existing.terminalId, existing.history);
411587
} else if (existing.status === "exited" || existing.status === "error") {
412588
existing.runtimeEnv = nextRuntimeEnv;
413589
existing.history = "";
590+
existing.pendingHistoryControlSequence = "";
414591
await this.persistHistory(existing.threadId, existing.terminalId, existing.history);
415592
} else if (currentRuntimeEnv !== nextRuntimeEnv) {
416593
existing.runtimeEnv = nextRuntimeEnv;
@@ -469,6 +646,7 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents>
469646
await this.runWithThreadLock(input.threadId, async () => {
470647
const session = this.requireSession(input.threadId, input.terminalId);
471648
session.history = "";
649+
session.pendingHistoryControlSequence = "";
472650
session.updatedAt = new Date().toISOString();
473651
await this.persistHistory(input.threadId, input.terminalId, session.history);
474652
this.emitEvent({
@@ -497,6 +675,7 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents>
497675
status: "starting",
498676
pid: null,
499677
history: "",
678+
pendingHistoryControlSequence: "",
500679
exitCode: null,
501680
exitSignal: null,
502681
updatedAt: new Date().toISOString(),
@@ -520,6 +699,7 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents>
520699
const rows = input.rows ?? session.rows;
521700

522701
session.history = "";
702+
session.pendingHistoryControlSequence = "";
523703
await this.persistHistory(input.threadId, input.terminalId, session.history);
524704
await this.startSession(session, { ...input, cols, rows }, "restarted");
525705
return this.snapshot(session);
@@ -694,9 +874,16 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents>
694874
}
695875

696876
private onProcessData(session: TerminalSessionState, data: string): void {
697-
session.history = capHistory(`${session.history}${data}`, this.historyLineLimit);
877+
const sanitized = sanitizeTerminalHistoryChunk(session.pendingHistoryControlSequence, data);
878+
session.pendingHistoryControlSequence = sanitized.pendingControlSequence;
879+
if (sanitized.visibleText.length > 0) {
880+
session.history = capHistory(
881+
`${session.history}${sanitized.visibleText}`,
882+
this.historyLineLimit,
883+
);
884+
this.queuePersist(session.threadId, session.terminalId, session.history);
885+
}
698886
session.updatedAt = new Date().toISOString();
699-
this.queuePersist(session.threadId, session.terminalId, session.history);
700887
this.emitEvent({
701888
type: "output",
702889
threadId: session.threadId,
@@ -713,6 +900,7 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents>
713900
session.pid = null;
714901
session.hasRunningSubprocess = false;
715902
session.status = "exited";
903+
session.pendingHistoryControlSequence = "";
716904
session.exitCode = Number.isInteger(event.exitCode) ? event.exitCode : null;
717905
session.exitSignal = Number.isInteger(event.signal) ? event.signal : null;
718906
session.updatedAt = new Date().toISOString();
@@ -736,6 +924,7 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents>
736924
session.pid = null;
737925
session.hasRunningSubprocess = false;
738926
session.status = "exited";
927+
session.pendingHistoryControlSequence = "";
739928
session.updatedAt = new Date().toISOString();
740929
this.killProcessWithEscalation(process, session.threadId, session.terminalId);
741930
this.evictInactiveSessionsIfNeeded();

apps/server/src/terminal/Services/Manager.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export interface TerminalSessionState {
3232
status: TerminalSessionStatus;
3333
pid: number | null;
3434
history: string;
35+
pendingHistoryControlSequence: string;
3536
exitCode: number | null;
3637
exitSignal: number | null;
3738
updatedAt: string;

0 commit comments

Comments
 (0)