Skip to content

Commit a13d2c4

Browse files
authored
fix(terminal): strip C1 OSC payloads from output (#103672)
1 parent a238dea commit a13d2c4

3 files changed

Lines changed: 27 additions & 14 deletions

File tree

packages/terminal-core/src/ansi.test.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,23 @@ describe("terminal ansi helpers", () => {
2424
it("strips the agent output escape grammar without changing text policy", () => {
2525
expect(stripAnsiSequences("\u001B[38:5:196mred\u001B[0m")).toBe("red");
2626
expect(stripAnsiSequences("\u009B31mred\u009B0m")).toBe("red");
27-
expect(stripAnsiSequences("\u001B]8;;https://openclaw.ai\u009Clink\u001B]8;;\u0007")).toBe(
28-
"link",
29-
);
3027
expect(stripAnsiSequences("line\n\t🙂\u001B]unterminated")).toBe("line\n\t🙂nterminated");
3128
expect(() => stripAnsiSequences(null as never)).toThrow("Expected a `string`, got `object`");
3229
});
3330

31+
it.each([
32+
["ESC OSC with BEL", "\u001B]", "\u0007"],
33+
["ESC OSC with ESC ST", "\u001B]", "\u001B\\"],
34+
["ESC OSC with C1 ST", "\u001B]", "\u009C"],
35+
["C1 OSC with BEL", "\u009D", "\u0007"],
36+
["C1 OSC with ESC ST", "\u009D", "\u001B\\"],
37+
["C1 OSC with C1 ST", "\u009D", "\u009C"],
38+
])("strips %s without clipping adjacent text", (_label, introducer, terminator) => {
39+
expect(stripAnsiSequences(`before🙂${introducer}0;title${terminator}after界`)).toBe(
40+
"before🙂after界",
41+
);
42+
});
43+
3444
it("sanitizes control characters for log-safe interpolation", () => {
3545
const input =
3646
"\u001B[31mwarn\u001B[0m" +

packages/terminal-core/src/ansi.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ const ESC_ANSI_CSI_PATTERN = "\\x1b\\[[\\x20-\\x3f]*[\\x40-\\x7e]";
33
const C1_ANSI_CSI_PATTERN = "\\x9b[\\x20-\\x3f]*[\\x40-\\x7e]";
44
const PARAMETERIZED_C1_ANSI_CSI_PATTERN = "\\x9b[\\x20-\\x3f]+[\\x40-\\x7e]";
55
const ANSI_CSI_PATTERN = `(?:${ESC_ANSI_CSI_PATTERN}|${C1_ANSI_CSI_PATTERN})`;
6-
// OSC: ESC ] <payload> ST. Covers OSC-8 hyperlinks and clipboard/title escapes.
6+
// OSC: ESC ] or C1 OSC, then <payload> ST. Covers hyperlinks and clipboard/title escapes.
77
// ST can be ESC \, BEL, or its C1 form.
8-
const ANSI_OSC_PATTERN = "(?:\\x1b\\]|\\x9d)[^\\x07\\x1b\\x9c]*(?:\\x1b\\\\|\\x07|\\x9c)";
8+
const ANSI_OSC_INTRODUCER_PATTERN = "(?:\\x1b\\]|\\x9d)";
9+
const ANSI_STRING_TERMINATOR_PATTERN = "(?:\\x1b\\\\|\\x07|\\x9c)";
10+
const ANSI_OSC_PATTERN = `${ANSI_OSC_INTRODUCER_PATTERN}[^\\x07\\x1b\\x9c]*${ANSI_STRING_TERMINATOR_PATTERN}`;
911

1012
const ANSI_CSI_REGEX = new RegExp(ANSI_CSI_PATTERN, "g");
1113
const ANSI_OSC_REGEX = new RegExp(ANSI_OSC_PATTERN, "g");
@@ -40,8 +42,7 @@ const SANITIZATION_ANSI_SEQUENCE_REGEX = new RegExp(
4042
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
4143
* SOFTWARE.
4244
*/
43-
const ANSI_STRING_TERMINATOR_PATTERN = "(?:\\u0007|\\u001B\\u005C|\\u009C)";
44-
const ANSI_OSC_SEQUENCE_PATTERN = `(?:\\u001B\\][\\s\\S]*?${ANSI_STRING_TERMINATOR_PATTERN})`;
45+
const ANSI_OSC_SEQUENCE_PATTERN = `${ANSI_OSC_INTRODUCER_PATTERN}[\\s\\S]*?${ANSI_STRING_TERMINATOR_PATTERN}`;
4546
const ANSI_CONTROL_SEQUENCE_PATTERN =
4647
"[\\u001B\\u009B][[\\]()#;?]*(?:\\d{1,4}(?:[;:]\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]";
4748
const ANSI_COMPAT_SEQUENCE_REGEX = new RegExp(
@@ -66,7 +67,9 @@ export function stripAnsiSequences(input: string): string {
6667
if (typeof input !== "string") {
6768
throw new TypeError(`Expected a \`string\`, got \`${typeof input}\``);
6869
}
69-
if (!input.includes("\u001B") && !input.includes("\u009B")) {
70+
// C1 OSC is independent of C1 CSI, so both 8-bit introducers must
71+
// participate in the fast path.
72+
if (!input.includes("\u001B") && !input.includes("\u009B") && !input.includes("\u009D")) {
7073
return input;
7174
}
7275
return input.replace(ANSI_COMPAT_SEQUENCE_REGEX, "");
@@ -163,8 +166,7 @@ function isWideEmojiGrapheme(grapheme: string): boolean {
163166
}
164167
// Minimally qualified ZWJ sequences still shape as one wide emoji in terminals.
165168
return (
166-
grapheme.includes("\u200D") &&
167-
(grapheme.match(extendedPictographicPattern)?.length ?? 0) >= 2
169+
grapheme.includes("\u200D") && (grapheme.match(extendedPictographicPattern)?.length ?? 0) >= 2
168170
);
169171
}
170172

src/gateway/terminal/buffer-text.test.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,14 @@ describe("renderTerminalBufferText", () => {
2020
});
2121

2222
it("drops the full residual C1 range without clipping adjacent Unicode text", () => {
23-
const c1 = Array.from({ length: 0x20 }, (_, offset) =>
24-
String.fromCharCode(0x80 + offset),
25-
).join("");
23+
const c1 = Array.from({ length: 0x20 }, (_, offset) => String.fromCharCode(0x80 + offset)).join(
24+
"",
25+
);
2626
expect(renderTerminalBufferText(`a\u007f${c1}\u00a0b\tc`)).toBe("a\u00a0b\tc");
2727
});
2828

29-
it("strips OSC title sequences", () => {
29+
it("strips OSC title sequences in ESC and C1 forms", () => {
3030
expect(renderTerminalBufferText("\u001b]0;title\u0007prompt$ ")).toBe("prompt$ ");
31+
expect(renderTerminalBufferText("\u009d0;title\u009cprompt$ ")).toBe("prompt$ ");
3132
});
3233
});

0 commit comments

Comments
 (0)