Skip to content

fix: drop non-JSONL garbage on codex app-server stdout#311

Open
lask3802 wants to merge 2 commits into
openai:mainfrom
lask3802:fix/jsonl-skip-non-json-garbage
Open

fix: drop non-JSONL garbage on codex app-server stdout#311
lask3802 wants to merge 2 commits into
openai:mainfrom
lask3802:fix/jsonl-skip-non-json-garbage

Conversation

@lask3802
Copy link
Copy Markdown

@lask3802 lask3802 commented May 9, 2026

Summary

  • Adds lib/jsonl.mjs#cleanProtocolLine(rawLine): strips ANSI/OSC escape sequences, trims whitespace, and returns the cleaned line only if it starts with a JSON delimiter ({ or [). Otherwise returns null.
  • Rewires lib/app-server.mjs handleLine() to skip on null instead of treating non-JSON lines as protocol violations that tear the connection down.
  • Adds tests/jsonl.test.mjs (11 cases) covering bracketed-paste prefix, OSC window-title, SGR colours, the exact CP-950 mojibake from Windows zh-TW: codex app-server JSONL parser crashes on Big5-encoded taskkill stdout leak #310, plain JSON passthrough, malformed-but-JSON-shaped fall-through to the parser, and non-string input.

Problem

handleLine() calls JSON.parse() on every line read from the codex app-server stdout pipe and, on failure, calls handleExit() — rejecting every in-flight request and closing the connection. In practice the codex CLI occasionally emits bytes onto stdout that have nothing to do with the protocol. Two known sources:

  1. Terminal / shell init noise (issue Review fails with JSONL parse error: bracketed paste mode escape sequence in output ([?2004h) #23). zsh writes the bracketed-paste marker \x1b[?2004h when a subprocess inherits the parent's TTY. The user-visible error:
    Failed to parse codex app-server JSONL: Unexpected token '', "[?2004h" is not valid JSON
    
  2. Localized OS messages on Windows non-English locales (issue Windows zh-TW: codex app-server JSONL parser crashes on Big5-encoded taskkill stdout leak #310 ⇄ root cause filed upstream as openai/codex#21957). On zh-TW (CP-950 / Big5), when codex.exe runs taskkill /T /F to clean up a failed MCP child and inadvertently routes taskkill's stdout to its own stdout, the JSONL stream receives:
    raw   : a6 a8 a5 5c 3a 20 50 49 44 20 ac b0 20 31 32 33 34 ...
    cp950 : 成功: PID 為 1234 ...
    utf-8 : ���\: PID �� 1234 ...
    
    which is the exact Unexpected token '�', "���\: PID "... reported in Windows zh-TW: codex app-server JSONL parser crashes on Big5-encoded taskkill stdout leak #310.

Both bug classes share the same correct fix at the plugin layer: lines that cannot possibly be JSONL records should be dropped, not treated as protocol violations. A JSONL record always begins with { or [. Any line whose first non-whitespace, post-strip-ANSI character is something else is definitively garbage from outside the protocol.

Why this approach over the existing PRs (#24, #97, #171)

#171 (the most recent of three open PRs) introduces a stripAnsi() helper and applies it before JSON.parse(). That correctly fixes #23 — but does not fix #310, because the leaked bytes are not ANSI escapes. They are localized OS text. After stripAnsi() the line is still not JSON, the parser still throws, the connection still dies.

The cleanProtocolLine() guard in this PR is a strict superset:

If maintainers prefer to land #171 first I'm happy to rebase this onto it (the regex code can move to lib/strings.mjs#stripAnsi and cleanProtocolLine becomes a one-liner that imports it). Either order works; the unit tests stay the same.

What this PR explicitly does NOT do

  • It does not patch the broker side (app-server-broker.mjs's socket data handler). That handler reads JSONRPC requests from the companion, which sends well-formed JSON; there is no observed corruption source on that path. fix: strip ANSI escape sequences from JSONL parsing #171 does patch it for symmetry — happy to add the same here if you'd like.
  • It does not fix the upstream stdio leak in codex.exe. That is filed as openai/codex#21957 and is out of scope for this repo. This PR is defense-in-depth for any embedder of codex app-server.

Test plan

  • node --test tests/jsonl.test.mjs — 11/11 pass.
  • node --test tests/*.test.mjs — all failures present on this branch (state.test.mjs, runtime.test.mjs, process.test.mjs — all Windows-only os.tmpdir() / MSYS path translation issues) reproduce against main from the same checkout, i.e. no regressions introduced by this PR.
  • End-to-end: stop-time review gate fired during a Claude Code session running on Windows zh-TW reliably reproduced the original failure pre-patch; with this patch applied to the plugin cache, three back-to-back codex-companion.mjs task invocations (each preceded by killing the broker to force the leaky direct-spawn path) now all return {"status":0,...}.
  • Manual: /codex:review from a zsh-default Claude Code session (issue Review fails with JSONL parse error: bracketed paste mode escape sequence in output ([?2004h) #23 case) — I do not have a zsh box handy; logically equivalent to PR fix: strip ANSI escape sequences from JSONL parsing #171's verification.

Closes / refs

…penai#23)

The JSONL parser in `lib/app-server.mjs` `handleLine()` previously called
`JSON.parse()` on every line read from the codex app-server stdout pipe
and, on failure, called `handleExit()` — rejecting every in-flight
request and tearing the connection down.

In practice the codex CLI occasionally emits non-JSON bytes onto stdout
that have nothing to do with the protocol. Two known sources:

  1. Terminal/shell init noise (issue openai#310 sibling-bug openai#23) — e.g. zsh
     writes the bracketed-paste marker `\x1b[?2004h` when a subprocess
     inherits the parent's TTY.

  2. Localized OS messages on Windows non-English locales (issue openai#310 +
     openai/codex#21957). On zh-TW (CP-950 / Big5), when codex.exe runs
     `taskkill /T /F` to clean up a failed MCP child and inadvertently
     routes taskkill's stdout to its own stdout, the JSONL stream
     receives the bytes `A6 A8 A5 5C 3A 20 50 49 44 ...`
     ("成功: PID 為 xxxx ..."), which decode under UTF-8 as
     `���\: PID ...` — the exact substring in the original error.

Both bug classes share the same correct shape of fix at the plugin
layer: lines that cannot possibly be JSONL records should be dropped,
not treated as protocol violations. A JSONL record always begins with
`{` or `[`. Any line whose first non-whitespace, post-strip-ANSI
character is something else is definitively garbage from outside the
protocol.

This commit factors a `cleanProtocolLine()` helper into `lib/jsonl.mjs`
that:

  - strips CSI / OSC ANSI escape sequences,
  - trims whitespace,
  - returns `null` if the result is empty or does not start with
    `{` / `[`,
  - otherwise returns the cleaned candidate string.

`handleLine()` skips the line on `null` and parses the candidate
otherwise. Lines that pass the guard but are still malformed JSON
continue to surface as real protocol errors via the existing
`handleExit()` path — that branch is preserved.

The root corruption — codex.exe leaking taskkill's stdout into its own
stdout on Windows zh-TW — is filed upstream as openai/codex#21957. This
PR is the plugin-side guard; it is also a strict superset of the
ANSI-escape strip approach explored in openai#24, openai#97, and openai#171, so any of
those can be closed in favour of this if the maintainers prefer.

Test plan:

  - `node --test tests/jsonl.test.mjs` — 11/11 pass, covers
    bracketed-paste prefix, OSC sequences, SGR colours, the exact
    CP-950 mojibake from openai#310, plain JSON passthrough, malformed-but-
    JSON-shaped fall-through to the parser, and non-string input.
  - The pre-existing test failures on this branch (`state`, `runtime`,
    `process` Windows tmpdir / MSYS quirks) are unrelated and reproduce
    on `main`.

Closes openai#310
Refs openai#23
Refs openai/codex#21957
@lask3802 lask3802 requested review from a team and Copilot May 9, 2026 18:55
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR hardens the Codex app-server JSONL reader against “garbage” lines on stdout by sanitizing/guarding input before parsing, preventing the client from tearing down the connection on clearly non-protocol output (e.g., terminal escape noise and localized Windows messages).

Changes:

  • Added cleanProtocolLine(rawLine) to strip ANSI/OSC escapes, trim whitespace, and drop lines that can’t be JSONL (don’t start with { or [).
  • Updated AppServerClientBase.handleLine() to skip dropped lines instead of treating them as fatal protocol violations.
  • Added unit tests covering ANSI/OSC stripping, mojibake/taskkill output, JSON passthrough, and malformed-but-JSON-shaped fall-through behavior.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
tests/jsonl.test.mjs Adds focused unit coverage for cleanProtocolLine behavior across real-world noise inputs.
plugins/codex/scripts/lib/jsonl.mjs Introduces the protocol-line cleaning/guard helper and ANSI/OSC stripping regex.
plugins/codex/scripts/lib/app-server.mjs Integrates cleanProtocolLine into the JSONL parsing path to avoid disconnecting on non-JSON lines.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread plugins/codex/scripts/lib/jsonl.mjs Outdated
Comment on lines +35 to +38
// CSI: ESC [ … final-byte (e.g. \x1b[?2004h, \x1b[31m)
// OSC: ESC ] … (BEL | ESC \) (e.g. window-title sets)
// eslint-disable-next-line no-control-regex
const ANSI_ESCAPE_RE = /\x1b\[[0-9;?]*[a-zA-Z]|\x1b\][^\x07]*(?:\x07|\x1b\\)/g;
Comment on lines +40 to +49
/**
* Returns a JSON-shaped candidate string for the given raw line, or
* `null` if the line should be skipped because it cannot be valid JSONL.
*
* @param {string} rawLine
* @returns {string | null}
*/
export function cleanProtocolLine(rawLine) {
if (typeof rawLine !== "string") {
return null;
…SDoc

Both addresses Copilot inline review on PR openai#311.

1. The CSI regex previously matched final bytes only in `[a-zA-Z]`, which
   misses valid CSI sequences that end on a non-letter. The most relevant
   missing case in this codebase is `\x1b[200~` / `\x1b[201~` —
   bracketed-paste content markers — which a TTY-attached subprocess
   emits *around* pasted content, not just the on/off `\x1b[?2004h` /
   `\x1b[?2004l` toggles. With the old regex, a JSON record sandwiched
   between `200~` and `201~` would survive `replace`, then fail the
   first-char `{`/`[` guard, then be silently dropped — the worst kind of
   regression for this fix's stated goal.

   Broaden to the ECMA-48 CSI grammar: parameter bytes in 0x30..0x3F,
   optional intermediate bytes in 0x20..0x2F, final byte in 0x40..0x7E.
   Adds a regression test for `\x1b[200~{...}\x1b[201~` and the boundary
   finals `@` (0x40) and `` ` `` (0x60).

2. The JSDoc declared `@param {string} rawLine` even though the function
   explicitly handles non-string input by short-circuiting to `null`
   (and a unit test asserts that). Update to `@param {unknown}` so
   editors and tsc-via-checkJs don't mislead callers into thinking they
   must type-narrow first.
@lask3802
Copy link
Copy Markdown
Author

lask3802 commented May 9, 2026

Thanks @copilot for the review — both points addressed in 2dc20bf:

  1. CSI final-byte range (jsonl.mjs:38). Good catch on \x1b[200~ / \x1b[201~ — bracketed-paste content markers (final byte ~) sit between the on/off toggles, so a letter-only final would have left them in place and then the { / [ guard would have dropped a perfectly valid JSON record sandwiched between them. Regex broadened to the full ECMA-48 CSI grammar (params 0x30..0x3F, intermediates 0x20..0x2F, final 0x40..0x7E). New regression test covers \x1b[200~{...}\x1b[201~ plus the boundary finals @ (0x40) and ` (0x60).

  2. JSDoc accuracy (jsonl.mjs:49). Updated @param to unknown to match the runtime contract (non-string short-circuits to null, asserted by a unit test). Note added in the prose too so editors don't mislead callers into pre-narrowing.

Full test run: node --test tests/jsonl.test.mjs → 12/12 pass.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants