Repository navigation
[Bug] Buffered parseResponse and the NDJSON frame root escape the #1240 non-record guard #2531
Description
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
parseResponsepath reached from
src/server/responses/core.ts:5065for non-streaming turns —googleandanthropicboth throw a
rawTypeErrorout of the adapter; - the NDJSON frame root in
command-code, which crashes onevent.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 |
unknown → isRec at every level |
clean |
openai-responses.ts:1838, 1891 |
unknown → isPlainObject |
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
lidge-jun commentedon Aug 25, 2026
리뷰 · 우선순위 63 / 80
설명: 이 이슈는 버퍼로 받은 응답 루트와 커맨드코드 NDJSON 한 줄이 레코드가 아닐 때 턴이 터지거나 빈 성공이 된다고 적는다. 작성자는 snowyukitty 다. 라벨은 bug, streaming, tools 다. 지금 CURRENT dev HEAD 는 98ed186 이다. 이번 시간에 SHA 는 안 움직였다. 구현 풀은 2532 다. 이슈를 연다. 닫지 말 것. 라벨은 바꾸지 말 것.
HEAD 에서 재현 길을 읽었다. command-code.ts 368줄과 379줄은 JSON.parse 를 try/catch 로만 감싼다. null 은 던지지 않는다. parseStream 527줄 event.type 이 TypeError 가 된다. google.ts 1161줄 raw.error, anthropic.ts 1282줄 json.content 가 같은 모양이다. anthropic 의 content 가 문자열이면 1288줄 for-of 가 글자 하나씩 돌고 빈 done 이 된다. openai-chat.ts 1934줄 if (!choice.message) 는 null 과 0 만 막는다. 문자열 true 배열은 통과한다. 이슈가 적은 G 표와 맞다.
이 구멍은 1240 이 닫은 SSE 프레임 루트와 다른 단이다. 1240 은 SSE 파서 네 개를 막았고, 나머지 여덟은 이미 맞다고 적었다. 버퍼 본문은 SSE 프레임이 아니다. 커맨드코드는 NDJSON 이다. 그래서 그 감사 범위 밖이다. kiro 쪽은 unknown 다음에 레코드 검사로 이미 닫혀 있다. 이슈 표의 clean 목록과 맞다. 호출 쪽 responses/core.ts 비스트림 경로는 어댑터 TypeError 를 구조화 에러로 안 감싼다. 가드가 throw 를 없애는 쪽이 맞다. core 에 catch 를 새로 넣지 말 것.
구현은 2532 다. 그 풀은 레디이고 베이스는 지금 HEAD 다. 이 이슈를 2532 가 머지되기 전에 닫지 말 것. 2532 본문의 Closes 2531 이 머지 뒤에 닫는다. 2423 빈 완료, 2472 빈 도구 결과, 2210 침묵 끊김은 이 이슈로 닫지 말 것. 그 구멍은 다른 길이다. types.ts 와 config.ts 가르기와는 무관하다. 프리뷰 배포가 아니다. 1240 을 다시 열지 말 것.
현장 증거가 오늘 위쪽이 이 본문을 낸다는 뜻은 아니다. 이슈도 그렇게 적는다. 1219 가 실제 위쪽이 data: null 패딩을 낸다는 것은 이미 있다. 커맨드코드 중간 null 은 그 모양의 NDJSON 판이다. 나머지는 같은 단을 맞추는 경화다. 내가 머지하지 않는다. 구현이 착지하기 전에 이슈를 닫지 말 것.
src/adapters/command-code.ts 368줄 - 줄 루프가 null 프레임을 이벤트로 넘겨 event.type 에서 턴이 죽는다
src/adapters/command-code.ts 379줄 - 끝 버퍼 분기가 같은 구멍을 따로 가지고 있다
src/adapters/google.ts 1161줄 - 본문 null 이 raw.error 에서 TypeError 가 된다
src/adapters/anthropic.ts 1282줄과 1288줄 - 본문 null 은 터지고 content 문자열은 빈 성공이 된다
src/adapters/openai-chat.ts 1934줄 - 참거짓 가드가 문자열 true 배열을 빈 성공으로 보낸다
2532 - 구현 풀이다. 이 이슈보다 먼저 닫지 말 것
메인테이너의 판단이 필요한 지점
너의 추천
이 이슈는 연다. 2532 를 기다린다. 2532 가 머지된 뒤에 닫는다. 지금은 닫지 말 것. 1240 을 다시 열지 말 것. 내가 머지하지 않는다. 라벨은 그대로 둔다. 프리뷰 배포가 아니다.
이 댓글은 grok-bot이 작성했습니다
lidge-jun commentedon Aug 25, 2026
Landed via #2532 at fea4538