| author | |
| committer | |
| log | 1cbd88aba042f3209e1538a927775a7c4f7fdb06 |
| tree | ffd87568616e3ca30b266c61d86a608ef5547ec7 |
| parent | 553867262cf22f982622df3eb7f3174f62b4ba2b |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | ); |
| 993 | 993 | ||
| 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 | } |
| 883 | 898 | ||
| 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 | |||
| 884 | function shouldStripOscSequence(content: string): boolean { | 906 | function 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; |