authorgravatar for 71498452+StiensWout@users.noreply.github.comWout Stiens <71498452+StiensWout@users.noreply.github.com> 2026-08-04 12:52:05+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-04 12:52:05+02:00
log1cbd88aba042f3209e1538a927775a7c4f7fdb06
treeffd87568616e3ca30b266c61d86a608ef5547ec7
parent553867262cf22f982622df3eb7f3174f62b4ba2b
signature Signed by PGP key B5690EEEBB952194

fix(server): strip replayable terminal queries from history (#5319)


2 files changed, 85 insertions(+), 2 deletions(-)

apps/server/src/terminal/Manager.test.ts+55
...@@ -991,6 +991,61 @@ it.layer(...@@ -991,6 +991,61 @@ it.layer(
991 }),991 }),
992 );992 );
993993
994 it.effect("strips replayable CSI and DCS traffic while preserving setters", () =>
995 Effect.gen(function* () {
996 const { manager, ptyAdapter } = yield* createManager();
997 yield* manager.open(openInput());
998 const process = ptyAdapter.processes[0];
999 expect(process).toBeDefined();
1000 if (!process) return;
1001
1002 process.emitData("prompt ");
1003 // DECRQM/DECRPM, XTVERSION, and kitty-keyboard CSI query/reply traffic.
1004 process.emitData("\u001b[?2026$p\u001b[?2026;2$y\u001b[>q\u001b[?u\u001b[?31u");
1005 // DECRQSS and XTGETTCAP query/reply traffic in 7-bit DCS form.
1006 process.emitData("\u001bP$q m\u001b\\\u001bP1$r0m\u001b\\");
1007 process.emitData("\u001bP+q544e\u001b\\\u001bP1+r544e=1b\u001b\\");
1008 // The same DCS traffic in 8-bit form.
1009 process.emitData("\u0090$q m\u009c\u00901$r0m\u009c");
1010 process.emitData("\u0090+q544e\u009c\u00901+r544e=1b\u009c");
1011 // Setters and cursor movement share final bytes with query families but
1012 // have visible terminal-state value and must survive replay.
1013 process.emitData('\u001b[!p\u001b["p\u001b[4 q\u001b[u');
1014 process.emitData("done\n");
1015
1016 yield* manager.close({ threadId: "thread-1" });
1017
1018 const reopened = yield* manager.open(openInput());
1019 assert.equal(reopened.history, 'prompt \u001b[!p\u001b["p\u001b[4 q\u001b[udone\n');
1020 }),
1021 );
1022
1023 it.effect("handles CSI and DCS query sequences split across output chunks", () =>
1024 Effect.gen(function* () {
1025 const { manager, ptyAdapter } = yield* createManager();
1026 yield* manager.open(openInput());
1027 const process = ptyAdapter.processes[0];
1028 expect(process).toBeDefined();
1029 if (!process) return;
1030
1031 process.emitData("before ");
1032 process.emitData("\u001b[?2026$");
1033 process.emitData("pafter ");
1034 process.emitData("\u001bP$q ");
1035 process.emitData("m\u001b");
1036 process.emitData("\\after ");
1037 process.emitData("\u009b?3");
1038 process.emitData("1uafter ");
1039 process.emitData("\u0090+q544e");
1040 process.emitData("\u009cafter\n");
1041
1042 yield* manager.close({ threadId: "thread-1" });
1043
1044 const reopened = yield* manager.open(openInput());
1045 assert.equal(reopened.history, "before after after after after\n");
1046 }),
1047 );
1048
994 it.effect(1049 it.effect(
995 "preserves clear and style control sequences while dropping chunk-split query traffic",1050 "preserves clear and style control sequences while dropping chunk-split query traffic",
996 () =>1051 () =>
apps/server/src/terminal/Manager.ts+30-2
...@@ -878,9 +878,31 @@ function shouldStripCsiSequence(body: string, finalByte: string): boolean {...@@ -878,9 +878,31 @@ function shouldStripCsiSequence(body: string, finalByte: string): boolean {
878 if (finalByte === "c" && /^[>0-9;?]*$/.test(body)) {878 if (finalByte === "c" && /^[>0-9;?]*$/.test(body)) {
879 return true;879 return true;
880 }880 }
881 // DECRQM mode queries (…$p) and DECRPM replies (…$y): replaying a stored
882 // query makes the terminal answer again, and the shell echoes the answer as
883 // junk at the prompt. The `$` guard keeps setters like DECSTR (!p) and
884 // DECSCL ("p) intact.
885 if ((finalByte === "p" || finalByte === "y") && /^[0-9;?]*\$$/.test(body)) {
886 return true;
887 }
888 // XTVERSION query (>q). DECSCUSR (space-intermediate q) stays.
889 if (finalByte === "q" && /^>[0-9;]*$/.test(body)) {
890 return true;
891 }
892 // Kitty keyboard protocol query/reply (?u). Restore-cursor (bare u) stays.
893 if (finalByte === "u" && body.startsWith("?")) {
894 return true;
895 }
881 return false;896 return false;
882}897}
883898
899// DECRQSS ($q) and XTGETTCAP (+q) queries plus their replies ([01]$r / [01]+r):
900// pure request/response traffic with no visual value, and replaying a stored
901// query triggers a fresh reply.
902function shouldStripDcsSequence(content: string): boolean {
903 return /^[01]?[$+][qr]/.test(content);
904}
905
884function shouldStripOscSequence(content: string): boolean {906function shouldStripOscSequence(content: string): boolean {
885 return /^(10|11|12);(?:\?|rgb:)/.test(content);907 return /^(10|11|12);(?:\?|rgb:)/.test(content);
886}908}
...@@ -981,7 +1003,10 @@ function sanitizeTerminalHistoryChunk(...@@ -981,7 +1003,10 @@ function sanitizeTerminalHistoryChunk(
981 }1003 }
982 const sequence = input.slice(index, terminatorIndex);1004 const sequence = input.slice(index, terminatorIndex);
983 const content = stripStringTerminator(input.slice(index + 2, terminatorIndex));1005 const content = stripStringTerminator(input.slice(index + 2, terminatorIndex));
984 if (nextCodePoint !== 0x5d || !shouldStripOscSequence(content)) {1006 const strip =
1007 (nextCodePoint === 0x5d && shouldStripOscSequence(content)) ||
1008 (nextCodePoint === 0x50 && shouldStripDcsSequence(content));
1009 if (!strip) {
985 append(sequence);1010 append(sequence);
986 }1011 }
987 index = terminatorIndex;1012 index = terminatorIndex;
...@@ -1024,7 +1049,10 @@ function sanitizeTerminalHistoryChunk(...@@ -1024,7 +1049,10 @@ function sanitizeTerminalHistoryChunk(
1024 }1049 }
1025 const sequence = input.slice(index, terminatorIndex);1050 const sequence = input.slice(index, terminatorIndex);
1026 const content = stripStringTerminator(input.slice(index + 1, terminatorIndex));1051 const content = stripStringTerminator(input.slice(index + 1, terminatorIndex));
1027 if (codePoint !== 0x9d || !shouldStripOscSequence(content)) {1052 const strip =
1053 (codePoint === 0x9d && shouldStripOscSequence(content)) ||
1054 (codePoint === 0x90 && shouldStripDcsSequence(content));
1055 if (!strip) {
1028 append(sequence);1056 append(sequence);
1029 }1057 }
1030 index = terminatorIndex;1058 index = terminatorIndex;