SIGN IN SIGN UP

fix(server): cap JSON nesting depth in AnyCodable (#462)

AnyCodable.init(from:) recursed once per level of JSON nesting with no depth
limit, and RawJSON decodes through it. RawJSON is the declared type of three
request fields an unauthenticated caller fully controls:
tools[].function.parameters and response_format.json_schema.schema on
/v1/chat/completions, and text.format.schema on /v1/responses.

Foundation's JSON scanner only rejects nesting at ~512 levels, far deeper than
the stack budget of the cooperative-pool thread the handler decodes on. Depths
between roughly 160 and 511 therefore passed the scanner and exhausted the
stack, aborting the whole process with SIGBUS -- a ~1.3 KB POST killed the
server and every in-flight request with it, and under launchd KeepAlive the
server restart-looped for as long as the sender repeated. The crash happened
during body decoding, before the handler ran, so --token did not protect
against it either.

The subtlety, and the reason the three candidate PRs (#467, #468, #469) were
not taken: a bare depth `guard` is not sufficient. init(from:) probes its two
container branches with `try?`, which swallows the thrown depth error, sets the
subtree to nil, and answers 200 with the caller's schema silently truncated.
That variant was built and measured -- it stops the crash and turns it into
silent data loss. (#467 and #468 additionally do not compile: their tests name
AnyCodable, which is internal to ApfelCore while Tests/apfelTests imports it
without @testable.)

So the depth failure is thrown as a real DecodingError -- so every existing
`catch is DecodingError`, ours and downstream consumers', still sees it and
still produces a 400 with no new error plumbing -- carrying a private
NestingLimitExceeded marker in underlyingError so the container probes can tell
"not that type" from "too deep" and re-throw only the latter.

Verified against the live server, all six deep-nesting cases:

  entry point                          depth 200   depth 400
  tools[].function.parameters          400, up     400, up
  response_format.json_schema.schema   400, up     400, up
  text.format.schema (/v1/responses)   400, up     400, up

A normal nested tool schema is unaffected: 200, finish_reason tool_calls.
1056 unit tests pass.

Closes #462

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ccLBbEaVVd4sJyUd5wyhA
A
Arthur Ficial committed
e511da21162d5042723228e46f1b4b219341d9ba
Parent: 7cbafa7