|
| 1 | +# Gateway 400 on every real session: don't police the upstream's protocol |
| 2 | + |
| 3 | +## The problem, in one line |
| 4 | + |
| 5 | +ccmr 1.8.3 added per-message schema validation (`role` must be user/assistant), and every |
| 6 | +real Claude Code v2.1+ session died locally with `400 messages[1].role must be user or |
| 7 | +assistant` — Claude Code sends harness context as `role: "system"` entries in `messages`. |
| 8 | + |
| 9 | +## The approach |
| 10 | + |
| 11 | +1. **Recognize whose error text it is before theorizing.** Grep the error string across the |
| 12 | + repo: `messages[1].role must be user or assistant` matched `requestValidationError` in |
| 13 | + `src/server.ts`. That instantly names the failing layer: the gateway's own local |
| 14 | + validation, not the upstream, not Claude Code. |
| 15 | +2. **Capture what the client actually sends — don't guess the payload.** Built a 3-piece |
| 16 | + loop in the scratchpad, no real keys, no cost: |
| 17 | + - fake Anthropic upstream (canned JSON + SSE responses) on one scratch port, |
| 18 | + - a ~30-line logging proxy that dumps every request body to `bodies/NNN-req.json` and |
| 19 | + forwards to the gateway, |
| 20 | + - the real gateway (`node dist/cli.js start -p <scratch> -c <scratch yaml>`), |
| 21 | + then ran the real client headless: `ANTHROPIC_BASE_URL=<proxy> claude -p "hi"` with a |
| 22 | + scratch `CLAUDE_CONFIG_DIR`. One run reproduced the user's exact error string. |
| 23 | +3. **Read the captured body.** `messages[1]` was `{role: "system", content: "Available |
| 24 | + agent types..."}` on `POST /v1/messages?beta=true`. Root cause proven, not inferred. |
| 25 | +4. **Minimize to one curl** (`messages: [{role:"user"...},{role:"system"...}]` → 400) — |
| 26 | + that shape became the regression test at the HTTP seam. |
| 27 | +5. **TDD the fix:** failing test first, then deleted the whole per-message validation loop. |
| 28 | + Re-ran the un-minimized loop (real headless `claude -p "hi"`) to confirm green end to end. |
| 29 | + |
| 30 | +## The judgment calls |
| 31 | + |
| 32 | +- **Did NOT whitelist `system` as a third allowed role.** That repeats the same mistake one |
| 33 | + protocol revision later. The gateway now validates only what routing depends on (body is |
| 34 | + an object, `model` string, `messages` is a non-empty array, `stream` boolean) and forwards |
| 35 | + everything else untouched. |
| 36 | +- **Did NOT touch the user's live gateway or real keys.** The whole loop ran on scratch |
| 37 | + ports with a fake upstream; the real client was the only real component — and it's the |
| 38 | + one whose behavior was in question. |
| 39 | +- **Did NOT trust memory of "what Claude Code sends".** The `role:"system"` entry is |
| 40 | + undocumented client behavior; only a live capture could establish it. |
| 41 | + |
| 42 | +## The reusable rule |
| 43 | + |
| 44 | +A passthrough gateway validates only the fields it routes on; every schema check beyond |
| 45 | +that is a bet against the client's future — when a proxy suddenly rejects real traffic, |
| 46 | +grep the error text to find whose validation fired, then capture live client traffic |
| 47 | +through a logging proxy instead of reasoning about what the client "should" send. |
0 commit comments