authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-24 10:34:41-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-24 10:34:41-07:00
log529315b4c8ec274980b06c41170f48b01fed6646
tree6ea006784d9df8a8bb07ec7a31cf492bd0cde4de
parent0a503d0cdeb4e6cd8a9952c930d6c61ce28104fa
signature Signed by PGP key B5690EEEBB952194

Preserve terminal history across ANSI control sequences (#1367)


3 files changed, 275 insertions(+), 2 deletions(-)

apps/server/src/terminal/Layers/Manager.test.ts+83
...@@ -469,6 +469,89 @@ describe("TerminalManager", () => {...@@ -469,6 +469,89 @@ describe("TerminalManager", () => {
469 manager.dispose();469 manager.dispose();
470 });470 });
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
472 it("deletes history file when close(deleteHistory=true)", async () => {555 it("deletes history file when close(deleteHistory=true)", async () => {
473 const { manager, ptyAdapter, logsDir } = makeManager();556 const { manager, ptyAdapter, logsDir } = makeManager();
474 await manager.open(openInput());557 await manager.open(openInput());
apps/server/src/terminal/Layers/Manager.ts+191-2
...@@ -254,6 +254,180 @@ function capHistory(history: string, maxLines: number): string {...@@ -254,6 +254,180 @@ function capHistory(history: string, maxLines: number): string {
254 return hasTrailingNewline ? `${capped}\n` : capped;254 return hasTrailingNewline ? `${capped}\n` : capped;
255}255}
256256
257function isCsiFinalByte(codePoint: number): boolean {
258 return codePoint >= 0x40 && codePoint <= 0x7e;
259}
260
261function 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
274function shouldStripOscSequence(content: string): boolean {
275 return /^(10|11|12);(?:\?|rgb:)/.test(content);
276}
277
278function 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
289function 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
302function isEscapeIntermediateByte(codePoint: number): boolean {
303 return codePoint >= 0x20 && codePoint <= 0x2f;
304}
305
306function isEscapeFinalByte(codePoint: number): boolean {
307 return codePoint >= 0x30 && codePoint <= 0x7e;
308}
309
310function 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
321function 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
257function legacySafeThreadId(threadId: string): string {431function legacySafeThreadId(threadId: string): string {
258 return threadId.replace(/[^a-zA-Z0-9._-]/g, "_");432 return threadId.replace(/[^a-zA-Z0-9._-]/g, "_");
259}433}
...@@ -378,6 +552,7 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents>...@@ -378,6 +552,7 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents>
378 status: "starting",552 status: "starting",
379 pid: null,553 pid: null,
380 history,554 history,
555 pendingHistoryControlSequence: "",
381 exitCode: null,556 exitCode: null,
382 exitSignal: null,557 exitSignal: null,
383 updatedAt: new Date().toISOString(),558 updatedAt: new Date().toISOString(),
...@@ -407,10 +582,12 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents>...@@ -407,10 +582,12 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents>
407 existing.cwd = input.cwd;582 existing.cwd = input.cwd;
408 existing.runtimeEnv = nextRuntimeEnv;583 existing.runtimeEnv = nextRuntimeEnv;
409 existing.history = "";584 existing.history = "";
585 existing.pendingHistoryControlSequence = "";
410 await this.persistHistory(existing.threadId, existing.terminalId, existing.history);586 await this.persistHistory(existing.threadId, existing.terminalId, existing.history);
411 } else if (existing.status === "exited" || existing.status === "error") {587 } else if (existing.status === "exited" || existing.status === "error") {
412 existing.runtimeEnv = nextRuntimeEnv;588 existing.runtimeEnv = nextRuntimeEnv;
413 existing.history = "";589 existing.history = "";
590 existing.pendingHistoryControlSequence = "";
414 await this.persistHistory(existing.threadId, existing.terminalId, existing.history);591 await this.persistHistory(existing.threadId, existing.terminalId, existing.history);
415 } else if (currentRuntimeEnv !== nextRuntimeEnv) {592 } else if (currentRuntimeEnv !== nextRuntimeEnv) {
416 existing.runtimeEnv = nextRuntimeEnv;593 existing.runtimeEnv = nextRuntimeEnv;
...@@ -469,6 +646,7 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents>...@@ -469,6 +646,7 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents>
469 await this.runWithThreadLock(input.threadId, async () => {646 await this.runWithThreadLock(input.threadId, async () => {
470 const session = this.requireSession(input.threadId, input.terminalId);647 const session = this.requireSession(input.threadId, input.terminalId);
471 session.history = "";648 session.history = "";
649 session.pendingHistoryControlSequence = "";
472 session.updatedAt = new Date().toISOString();650 session.updatedAt = new Date().toISOString();
473 await this.persistHistory(input.threadId, input.terminalId, session.history);651 await this.persistHistory(input.threadId, input.terminalId, session.history);
474 this.emitEvent({652 this.emitEvent({
...@@ -497,6 +675,7 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents>...@@ -497,6 +675,7 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents>
497 status: "starting",675 status: "starting",
498 pid: null,676 pid: null,
499 history: "",677 history: "",
678 pendingHistoryControlSequence: "",
500 exitCode: null,679 exitCode: null,
501 exitSignal: null,680 exitSignal: null,
502 updatedAt: new Date().toISOString(),681 updatedAt: new Date().toISOString(),
...@@ -520,6 +699,7 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents>...@@ -520,6 +699,7 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents>
520 const rows = input.rows ?? session.rows;699 const rows = input.rows ?? session.rows;
521700
522 session.history = "";701 session.history = "";
702 session.pendingHistoryControlSequence = "";
523 await this.persistHistory(input.threadId, input.terminalId, session.history);703 await this.persistHistory(input.threadId, input.terminalId, session.history);
524 await this.startSession(session, { ...input, cols, rows }, "restarted");704 await this.startSession(session, { ...input, cols, rows }, "restarted");
525 return this.snapshot(session);705 return this.snapshot(session);
...@@ -694,9 +874,16 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents>...@@ -694,9 +874,16 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents>
694 }874 }
695875
696 private onProcessData(session: TerminalSessionState, data: string): void {876 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 }
698 session.updatedAt = new Date().toISOString();886 session.updatedAt = new Date().toISOString();
699 this.queuePersist(session.threadId, session.terminalId, session.history);
700 this.emitEvent({887 this.emitEvent({
701 type: "output",888 type: "output",
702 threadId: session.threadId,889 threadId: session.threadId,
...@@ -713,6 +900,7 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents>...@@ -713,6 +900,7 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents>
713 session.pid = null;900 session.pid = null;
714 session.hasRunningSubprocess = false;901 session.hasRunningSubprocess = false;
715 session.status = "exited";902 session.status = "exited";
903 session.pendingHistoryControlSequence = "";
716 session.exitCode = Number.isInteger(event.exitCode) ? event.exitCode : null;904 session.exitCode = Number.isInteger(event.exitCode) ? event.exitCode : null;
717 session.exitSignal = Number.isInteger(event.signal) ? event.signal : null;905 session.exitSignal = Number.isInteger(event.signal) ? event.signal : null;
718 session.updatedAt = new Date().toISOString();906 session.updatedAt = new Date().toISOString();
...@@ -736,6 +924,7 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents>...@@ -736,6 +924,7 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents>
736 session.pid = null;924 session.pid = null;
737 session.hasRunningSubprocess = false;925 session.hasRunningSubprocess = false;
738 session.status = "exited";926 session.status = "exited";
927 session.pendingHistoryControlSequence = "";
739 session.updatedAt = new Date().toISOString();928 session.updatedAt = new Date().toISOString();
740 this.killProcessWithEscalation(process, session.threadId, session.terminalId);929 this.killProcessWithEscalation(process, session.threadId, session.terminalId);
741 this.evictInactiveSessionsIfNeeded();930 this.evictInactiveSessionsIfNeeded();
apps/server/src/terminal/Services/Manager.ts+1
...@@ -32,6 +32,7 @@ export interface TerminalSessionState {...@@ -32,6 +32,7 @@ export interface TerminalSessionState {
32 status: TerminalSessionStatus;32 status: TerminalSessionStatus;
33 pid: number | null;33 pid: number | null;
34 history: string;34 history: string;
35 pendingHistoryControlSequence: string;
35 exitCode: number | null;36 exitCode: number | null;
36 exitSignal: number | null;37 exitSignal: number | null;
37 updatedAt: string;38 updatedAt: string;