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", () => {
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+191-2
......@@ -254,6 +254,180 @@ function capHistory(history: string, maxLines: number): string {
254254 return hasTrailingNewline ? `${capped}\n` : capped;
255255}
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
257431function 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+1
......@@ -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;