Skip to content

Commit bdb85ee

Browse files
committed
fix(server): strip replayable terminal queries from history
1 parent a261a64 commit bdb85ee

2 files changed

Lines changed: 85 additions & 2 deletions

File tree

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

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -991,6 +991,61 @@ it.layer(
991991
}),
992992
);
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+
9941049
it.effect(
9951050
"preserves clear and style control sequences while dropping chunk-split query traffic",
9961051
() =>

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

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -878,9 +878,31 @@ function shouldStripCsiSequence(body: string, finalByte: string): boolean {
878878
if (finalByte === "c" && /^[>0-9;?]*$/.test(body)) {
879879
return true;
880880
}
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+
}
881896
return false;
882897
}
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.
902+
function shouldStripDcsSequence(content: string): boolean {
903+
return /^[01]?[$+][qr]/.test(content);
904+
}
905+
884906
function shouldStripOscSequence(content: string): boolean {
885907
return /^(10|11|12);(?:\?|rgb:)/.test(content);
886908
}
@@ -981,7 +1003,10 @@ function sanitizeTerminalHistoryChunk(
9811003
}
9821004
const sequence = input.slice(index, terminatorIndex);
9831005
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) {
9851010
append(sequence);
9861011
}
9871012
index = terminatorIndex;
@@ -1024,7 +1049,10 @@ function sanitizeTerminalHistoryChunk(
10241049
}
10251050
const sequence = input.slice(index, terminatorIndex);
10261051
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) {
10281056
append(sequence);
10291057
}
10301058
index = terminatorIndex;

0 commit comments

Comments
 (0)