From 5edf7ad636c60f0cd3fa0ad82ea4b7632299f44f Mon Sep 17 00:00:00 2001 From: rohitsinghmansa Date: Tue, 7 Apr 2026 13:28:50 +0530 Subject: [PATCH] fix: strip ANSI escape sequences from JSONL lines before parsing Shells like zsh emit terminal control sequences (e.g. bracketed paste mode `\e[?2004h`) into subprocess stdout during shell initialization. When the Codex app-server reads these as JSONL, JSON.parse() fails with "Unexpected token" errors. Add a `stripAnsi()` utility that removes CSI and OSC escape sequences, and apply it in both JSONL parsing paths (SpawnedCodexAppServerClient and the broker socket handler) before attempting JSON.parse(). Fixes #23 Co-Authored-By: Claude Opus 4.6 --- plugins/codex/scripts/app-server-broker.mjs | 6 ++-- plugins/codex/scripts/lib/app-server.mjs | 6 ++-- plugins/codex/scripts/lib/strings.mjs | 20 +++++++++++ tests/strings.test.mjs | 37 +++++++++++++++++++++ 4 files changed, 65 insertions(+), 4 deletions(-) create mode 100644 plugins/codex/scripts/lib/strings.mjs create mode 100644 tests/strings.test.mjs diff --git a/plugins/codex/scripts/app-server-broker.mjs b/plugins/codex/scripts/app-server-broker.mjs index 1954274..60076f2 100644 --- a/plugins/codex/scripts/app-server-broker.mjs +++ b/plugins/codex/scripts/app-server-broker.mjs @@ -8,6 +8,7 @@ import process from "node:process"; import { parseArgs } from "./lib/args.mjs"; import { BROKER_BUSY_RPC_CODE, CodexAppServerClient } from "./lib/app-server.mjs"; import { parseBrokerEndpoint } from "./lib/broker-endpoint.mjs"; +import { stripAnsi } from "./lib/strings.mjs"; const STREAMING_METHODS = new Set(["turn/start", "review/start", "thread/compact/start"]); @@ -124,11 +125,12 @@ async function main() { buffer += chunk; let newlineIndex = buffer.indexOf("\n"); while (newlineIndex !== -1) { - const line = buffer.slice(0, newlineIndex); + const rawLine = buffer.slice(0, newlineIndex); buffer = buffer.slice(newlineIndex + 1); newlineIndex = buffer.indexOf("\n"); - if (!line.trim()) { + const line = stripAnsi(rawLine).trim(); + if (!line) { continue; } diff --git a/plugins/codex/scripts/lib/app-server.mjs b/plugins/codex/scripts/lib/app-server.mjs index fec105c..53407e1 100644 --- a/plugins/codex/scripts/lib/app-server.mjs +++ b/plugins/codex/scripts/lib/app-server.mjs @@ -13,6 +13,7 @@ import process from "node:process"; import { spawn } from "node:child_process"; import readline from "node:readline"; import { parseBrokerEndpoint } from "./broker-endpoint.mjs"; +import { stripAnsi } from "./strings.mjs"; import { ensureBrokerSession } from "./broker-lifecycle.mjs"; import { terminateProcessTree } from "./process.mjs"; @@ -114,8 +115,9 @@ class AppServerClientBase { } } - handleLine(line) { - if (!line.trim()) { + handleLine(rawLine) { + const line = stripAnsi(rawLine).trim(); + if (!line) { return; } diff --git a/plugins/codex/scripts/lib/strings.mjs b/plugins/codex/scripts/lib/strings.mjs new file mode 100644 index 0000000..648eaab --- /dev/null +++ b/plugins/codex/scripts/lib/strings.mjs @@ -0,0 +1,20 @@ +/** + * Strip ANSI escape sequences from a string. + * + * Terminals (and shells like zsh/bash) may emit control sequences such as + * bracketed-paste-mode markers (`\e[?2004h`) into subprocess stdout. When + * the JSONL protocol reader encounters these bytes it fails to parse the + * line as JSON. Stripping them before `JSON.parse()` makes the protocol + * resilient to noisy terminal environments. + * + * Covers: + * - CSI sequences \x1b[ … (e.g. \x1b[?2004h, \x1b[0m) + * - OSC sequences \x1b] … (e.g. window-title sets) + * + * @param {string} text + * @returns {string} + */ +export function stripAnsi(text) { + // eslint-disable-next-line no-control-regex + return text.replace(/\x1b\[[0-9;?]*[a-zA-Z]|\x1b\][^\x07]*(?:\x07|\x1b\\)/g, ""); +} diff --git a/tests/strings.test.mjs b/tests/strings.test.mjs new file mode 100644 index 0000000..6f17d0b --- /dev/null +++ b/tests/strings.test.mjs @@ -0,0 +1,37 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { stripAnsi } from "../plugins/codex/scripts/lib/strings.mjs"; + +test("stripAnsi removes bracketed paste mode sequences", () => { + assert.equal(stripAnsi('\x1b[?2004h{"id":1}'), '{"id":1}'); + assert.equal(stripAnsi('{"id":1}\x1b[?2004l'), '{"id":1}'); +}); + +test("stripAnsi removes SGR color codes", () => { + assert.equal(stripAnsi('\x1b[0m{"id":1}\x1b[1;31m'), '{"id":1}'); +}); + +test("stripAnsi removes OSC sequences (BEL terminated)", () => { + assert.equal(stripAnsi('\x1b]0;title\x07{"id":1}'), '{"id":1}'); +}); + +test("stripAnsi removes OSC sequences (ST terminated)", () => { + assert.equal(stripAnsi('\x1b]0;title\x1b\\{"id":1}'), '{"id":1}'); +}); + +test("stripAnsi passes through clean JSON unchanged", () => { + const json = '{"jsonrpc":"2.0","id":1,"method":"initialize"}'; + assert.equal(stripAnsi(json), json); +}); + +test("stripAnsi handles empty string", () => { + assert.equal(stripAnsi(""), ""); +}); + +test("stripAnsi handles multiple escape sequences in one line", () => { + assert.equal( + stripAnsi('\x1b[?2004h\x1b[0m{"id":1}\x1b[?2004l'), + '{"id":1}' + ); +});