Skip to content

[Bug] Buffered parseResponse and the NDJSON frame root escape the #1240 non-record guard #2531

Description

@snowyukitty

Client or integration

Codex CLI via the OpenCodex proxy (non-streaming turns), plus the Command Code provider.

Area

Adapters — anthropic, google, openai-chat, command-code

Summary

JSON.parse("null") returns null without throwing, so a try/catch around a body parse cannot see
it. #1240 closed that at the SSE frame root for the four SSE parsers. Two rungs were never swept,
because neither is an SSE frame parser:

  • the buffered body root, on the parseResponse path reached from
    src/server/responses/core.ts:5065 for non-streaming turns — google and anthropic both throw a
    raw TypeError out of the adapter;
  • the NDJSON frame root in command-code, which crashes on event.type.

Two more sites accept a malformed claimed response and report success with no content:
anthropic's content and openai-chat's choice.message.

#1240's own audit statement names the gap precisely: "The other eight SSE data-frame parsers were
audited and are already correct."
A buffered body is not an SSE data frame, and Command Code is
NDJSON rather than SSE, so neither was ever in scope.

Where

# Site Trigger Result
A src/adapters/command-code.ts ndjson() (newline loop and trailing-buffer branch) any frame is null TypeError: null is not an object (evaluating 'event.type')
B src/adapters/google.ts parseResponse body is null TypeError: null is not an object (evaluating 'raw.error')
C src/adapters/anthropic.ts parseResponse body is null TypeError: null is not an object (evaluating 'json.content')
D src/adapters/anthropic.ts parseResponse content is true or {} TypeError: … is not iterable
E src/adapters/anthropic.ts parseResponse content is [null], or content[i] is null TypeError: null is not an object (evaluating 'block.type')
F src/adapters/anthropic.ts parseResponse content is a string no throw — silent data loss. A string is iterable, so the loop walks a claimed answer one character at a time, reads undefined from every block.type, emits nothing, and reports a clean done.
G src/adapters/openai-chat.ts parseResponse message is "txt", true, or [{…}] no throw — silent data loss. See below.

G is the sharpest. The guard is if (!choice.message) — a truthiness test, so the same input
class splits on truthiness rather than shape:

choice.message Result
null error — fails closed ✅
0 error — fails closed ✅
"ANSWER" successful empty turn
true successful empty turn
[{"role":"assistant","content":"ANSWER"}] successful empty turn, answer discarded

That last row is #2232's content: [{ parts: [...] }] one adapter over. The guard sits one line
below
a full record check on the choice container itself — the message, which holds the assistant's
actual output, was left on a truthiness test. A choice with finish_reason: "stop" and a malformed
message reports success while silently stranding any tool call it claimed.

Checked and clean — not every site is affected

Wire JSON enters through exactly two doors, JSON.parse and response.json(), so this is a census
rather than a sample. Every response-path site in src/adapters/ and src/web-search/ was
enumerated and its guard read:

Site Guard Verdict
kiro-events.ts:36, kiro.ts:1095, kiro-truncation.ts:23 unknown → catch → record predicate → malformed(), plus typed per-field accessors clean
cursor/live-transport.ts:200, 1640 optional chaining clean
cursor/protobuf-events.ts (6 sites) discarded probe, or unknown + record predicate clean
web-search/parse.ts:236 explicit non-record guard clean (#1240)
web-search/{anthropic,xai,gemini,exa}-executor.ts unknownisRec at every level clean
openai-responses.ts:1838, 1891 unknownisPlainObject clean
anthropic.ts:258, openai-chat.ts:193 unknown → guarded extractor clean
anthropic.ts:1112, google.ts:846, openai-chat.ts:1623 non-record frame guard clean (#1240)

Kiro is worth naming as the positive example: the largest adapter here, and the only one that
validates every field at the boundary (optionalString, optionalBoolean, tokenCount, each
failing closed with a named reason). This report is not asking for new invention — it is asking the
other adapters to do what kiro already does.

One honest non-finding: mimo-free.ts:151 casts a bootstrap body to { jwt?: string }, so null
throws a TypeError where the next line intends Error("MiMo bootstrap returned no JWT"). Both
fail closed
— error-message quality on a credential path, not a defect of this class.

Reproduction

Against dev@98ed186c:

import { createAnthropicAdapter } from "./src/adapters/anthropic";
import { createOpenAIChatAdapter } from "./src/adapters/openai-chat";
import { createTranslatorBudget } from "./src/lib/translator-budget";

const json = (b: string) => new Response(b, { headers: { "content-type": "application/json" } });
const anthropic = { adapter: "anthropic", baseUrl: "https://example.test/v1", apiKey: "k", authMode: "key" };
const chat = { adapter: "openai-chat", baseUrl: "https://example.test/v1", apiKey: "k", authMode: "key" };

// C — throws TypeError: null is not an object (evaluating 'json.content')
await createAnthropicAdapter(anthropic).parseResponse!(json("null"), createTranslatorBudget());

// F — no throw; resolves to [{ type: "done", stopReason: "end_turn" }], the answer discarded
await createAnthropicAdapter(anthropic).parseResponse!(
  json('{"type":"message","role":"assistant","content":"a claimed answer","stop_reason":"end_turn"}'),
  createTranslatorBudget(),
);

// G — no throw; resolves to [{ type: "done" }], text and tool call both discarded
await createOpenAIChatAdapter(chat).parseResponse!(
  json('{"choices":[{"message":[{"role":"assistant","content":"ANSWER"}],"finish_reason":"stop","index":0}]}'),
  createTranslatorBudget(),
);

google (B) reproduces with a body of null through createGoogleAdapter(...).parseResponse.
command-code (A) reproduces with a bare null line anywhere in an NDJSON stream, including between
two text-delta frames whose text has already arrived.

Logs or error output

TypeError: null is not an object (evaluating 'event.type')   // command-code ndjson
TypeError: null is not an object (evaluating 'raw.error')    // google parseResponse
TypeError: null is not an object (evaluating 'json.content') // anthropic parseResponse
TypeError: true is not iterable                              // anthropic content
TypeError: null is not an object (evaluating 'block.type')   // anthropic content[i]
(no error)                                                   // F and G: success, no content

Version

dev@98ed186c (package version 2.27.0).

Operating system

Windows 11 Pro 26200.

Notes

No field evidence that a provider emits these today. #1219 established that a real upstream does emit
data: null padding between content deltas, which is why the frame-root case is not hypothetical;
the rest is hardening by parity with sibling adapters that already refuse the same shapes.

Found by an enumerative sweep rather than by a report — 2,937 single-location mutations across five
adapters in both modes, plus the census above. The method and its limits are described in the PR.

Checks

  • I searched existing issues and documentation.
  • I removed secrets, tokens, account details, request credentials, and personal data.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workinglanded-via-maintainerOriginal PR closed after landing via a maintainer merge trainstreamingSSE, WebSocket, terminal stream framestoolstool_calls, MCP, web-search / sidecar tools

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions