Skip to content

Check Agents API JSON bodies in one shared gate - #83

Merged
SaladDay merged 12 commits into
mainfrom
codex/http-json-body
Sep 23, 2026
Merged

SaladDay merged 12 commits into
mainfrom
codex/http-json-body

Conversation

@SaladDay

@SaladDay SaladDay commented Sep 23, 2026 •

Copy link
Copy Markdown
Collaborator

Every Agents API JSON request body now passes one shared gate that follows the official parse semantics. This fixes two data-integrity problems:

  • invalid UTF-8 used to be stored as U+FFFD;
  • with duplicate keys, the last value silently won.

It also stops writes the official service rejects, such as a missing or wrong Content-Type whose update still applied. The pinned baseline is unchanged (SDK 3.13.0 / d7c41ef / agents=v1).

Behavior

All rejections are 400 with type and code invalid_request_error and param null, unless noted, with the exact official messages. The order is: authentication and Beta, then Content-Type, then the existing body limit and 413, then UTF-8, syntax, duplicates, root type.

  • Content-Type: a missing, non-JSON or malformed media type gets "expected request with Content-Type: application/json". application/json and application/*+json are accepted with parameters, case-insensitively, parsed with mime.ParseMediaType. The check is not applied to DELETE, multipart Files/Skills uploads, the non-Beta Skills update, /core/v1, or the daemon, sandbox and node routes.
  • UTF-8: invalid bytes get "Invalid body: encountered a unicode decode error when parsing this JSON value. …". Nothing is stored.
  • Syntax: malformed JSON, trailing bytes, two values, a BOM, a whitespace-only body, and lone or mis-paired surrogate escapes get "Invalid body: failed to parse JSON value. …". The surrogate case follows official evidence. Agent update no longer returns unsupported_or_invalid_configuration for these.
  • Duplicate keys at any depth: "Invalid body: duplicate JSON key '' at ''. Duplicate JSON keys are not supported." The path omits array indices, as observed. Keys are compared after unescaping. Key and path are echoed only under the shared bounded-echo rule. This replaces Core's local "Duplicate parameter" error and every last-value-wins path. The scanner runs in linear time and keeps offsets plus hash bits instead of copying keys, so a body of many short keys costs about twice its size in the gate.
  • Root type: a non-object root gets "Invalid type: expected an object, but got instead." The official treatment of [] as {} is an upstream anomaly and is not copied.
  • Empty body or null: treated as {}, so create reports the missing field and update becomes the documented empty update.
  • Case variants: keys such as Metadata, Input or a nested Role no longer map to the real field through case-insensitive decoding. They are unknown members, rejected with each route's existing unknown-field error. The official service also treats them as distinct keys.
  • Valid bodies: unchanged, including unknown-field errors, configuration validation, 413 and x_agents_core.

Evidence

  • HP-09..15 (campaign scan 6).
  • Two further owned probes on 2026-09-24 that created nothing:
    • a lone surrogate gets the parse error (req_1a9b7680d615454ca97c816b25e2f401);
    • metadata and Metadata are distinct keys (req_6ba2a50c71a4410f87a1baac855e82df).
  • Recorded in official-semantics-alignment.md, operation-evidence.md (register JB, rows 1 and 3) and CONTRIBUTING.

Validation

  • Independent acceptance, written from the requirements only: real Core with PostgreSQL, exact bytes over http.client plus the pinned SDK, all 32 official probes replayed, no official or model calls.

    Row main candidate
    B1 syntax FAIL PASS 170/170
    B2 invalid UTF-8 (main stored U+FFFD in 7 tables) FAIL PASS 135/135
    B3 duplicate keys (main wrote last values) FAIL PASS 74/74
    B4 root type FAIL PASS 160/160
    B5 empty / null FAIL PASS 70/70
    B6 Content-Type (main applied 77 such requests) FAIL PASS 236/236
    Order FAIL PASS 74/74
    B7 valid bodies, exclusions, tenant B PASS PASS

    An addendum covers the follow-ups:

    • surrogates: 53/53;
    • case variants: 87/87 across Agent, Session, Vault, Credential and Template routes and events, with no write;
    • malformed media types: 38/38;
    • near-limit many-key bodies: 14/14, with no added time over main.

    The original script was unchanged between runs (same sha256).

  • Tests:

    • Go tests for every route family and the order.
    • A differential test comparing the scanner with an encoding/json token walk on random bodies, including objects with more than 16 members.
    • A memory benchmark.
    • A real-PostgreSQL test sending every rejection to every route as owner and tenant B under a whole-database digest.
    • The excluded routes on real storage.
    • The pinned-SDK official client suite.
  • Callers checked: the TS client, Core Web, the console proxy, install acceptance and the Python scripts all send application/json. The product repository calls Core through openai-go.

  • Server gate on this head: all make check targets, plus Web typecheck, core-doctor, unit tests and build, pass. The Playwright browser cases were not run on the server (no Google Chrome; the user approved the skip). make openapi is byte-identical.

  • Independent reviews by fresh Claude Code subagents (the user-approved replacement for GPT-6 Astra):

    • First review: found no blocker. Its follow-ups were memory, lone surrogates, case-variant keys, media-type parsing and test strength; all are fixed.
    • Focused re-review of those changes: no blocker. It confirmed the scanner, surrogate check, exact-case matching and media-type parsing with 136k differential bodies and 1.4M fuzz runs. Its memory follow-up is fixed: exact-case member checks now walk the body bytes without copying, and body reads use a doubling buffer. A 16 MiB Session create of top-level unknown keys now allocates about 96 MiB, where main allocated 482 MiB; nested unknown keys stay linear and at or below main.
    • Final focused review of that round: no blocker. A 109,829-body differential against encoding/json over 38 request types found no mismatch, and forced hash collisions pass. Its follow-ups are applied in the last two commits: a latent panic on an unpaired surrogate in a direct helper call, which HTTP can't reach, and precise memory wording. They were verified by focused tests and the gate on the final head.

No full protocol compatibility is claimed.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith with what you need. Autofix is disabled.

Every Agents API JSON route now reads its body through readJSONObject
before route-specific decoding, validation or lookup. It requires a JSON
Content-Type, applies the route's body limit, then rejects invalid UTF-8,
malformed JSON, repeated keys at any depth and non-object roots with the
official messages, and treats a zero-length body or null as {} (HP-09..15).
The local 'Duplicate parameter' error and the metadata last-value path are
retired.
The Agent, Agent update, Vault, Credential, Credential rotation and Session
metadata scripts send raw bytes with and without the JSON Content-Type and
assert the shared gate's official messages without writes (HP-09..15). A
zero-length body or null is {} where the route accepts it.
Record the HP-09..15 alignment rows, register the JB evidence code and
describe the gate in CONTRIBUTING and the Agent create annotation.
The duplicate-key scan keeps key positions in the body instead of copying
every key: small objects compare hashed positions and objects with more than
16 keys use an open-addressing set of 4-byte positions, compared after
unescaping. A 16 MiB body of short keys now allocates 16 MiB instead of
116 MiB, still in one linear pass.

The same pass rejects string escapes that form a lone or mis-paired UTF-16
surrogate with the official parse error (req_1a9b7680d615454ca97c816b25e2f401).
The JSON Content-Type is parsed with mime.ParseMediaType, so malformed media
types get the Content-Type error.

Tests extend the differential check to large objects, bound allocations on
many short keys, prove the Beta-before-Content-Type order and drop the
excluded-route test that did not reach its routes.
encoding/json matched a case variant such as Metadata, Input or a nested
Role to the field and let the last copy win, so Session create, events and
nested credential and input objects accepted and stored it. The official
service treats such names as distinct keys
(req_6ba2a50c71a4410f87a1baac855e82df). caseVariantMember makes a case
variant an unknown member at any struct depth, in decodeInputObject and the
Session create and events decoders, rejected with each route's existing
unknown-member error before any write. The Credential auth type is read
exactly too.
A PostgreSQL test sends DELETE, Files and Skills uploads, Skills update and
executor credential requests with real storage and shows they keep their
own body handling. The Vault, Credential and rotation scripts recount or
reread their resources around the body gate checks.
…buffer

The duplicate-key set stores 32 hash bits beside each key position, so
probes compare keys only when the bits match. Duplicate paths are built
with exact-size strings, and the deep-body test now bounds its allocation.
The shared reader grows its buffer by doubling: io.ReadAll allocated about
five times a large body.
caseVariantMember loaded every object level into a map and added about
231 MiB to a 16 MiB Session create. inexactMember walks the bytes once
without copying them and reports the first key that is not exactly a
member name, unknown or a case variant, before the decoder, which with
DisallowUnknownFields formatted an error for every unknown key. The
Session metadata check reads only the metadata member. A 16 MiB Session
create of unknown keys now allocates about 96 MiB instead of 482 MiB, and a
route-level test bounds Session create and events. The nested cases now
fail only without the check.
appendUnescaped assumed paired surrogates and sliced past the end of an
exact-capacity key such as "\ud800". HTTP cannot reach it, as the gate
rejects these escapes first, but inexactMember could. Like encoding/json it
now decodes an unpaired or mis-paired surrogate as U+FFFD, so the key
matches no member, and keeps a truncated escape as it is. Tests call both
functions on lone and mis-paired surrogates in keys, nested objects and
arrays.
The 96 MiB Session create figure holds for unknown top-level keys; unknown
keys under agent or environment keep the existing object decoding, linear
and at or below main. The doubling reader allocates two to four times the
body, against 4.4 to 6.1 times for io.ReadAll. Correct a stale test comment
on case-insensitive names.
@SaladDay
SaladDay merged commit 711df5c into main Sep 23, 2026
2 of 3 checks passed
@SaladDay
SaladDay deleted the codex/http-json-body branch October 7, 2026 06:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant