Repository navigation
1.9.79: a hosted call refuses answers with more JSON values than it decodes - #281
Conversation
…ecodes
The hosted relay holds each call to 1 MiB of the install's answers, and an
engine's memory follows the JSON values it decodes, not the bytes: 1 MiB of
{"a":0} grew a js/wasm engine to 83 MiB (136.5 on report_timeseries, which
decodes its answer twice), and three such engines end the isolate they
share. A hosted run now counts values before anything decodes them:
- api.readAnswer is the one reader of an answer's body in the client (data
answers, error answers, the capabilities and version answers), held there
by TestAnswerBodiesAreReadOnlyByReadAnswer. Under LimitAnswerValues it
counts every answer of the call together, since a command that reads
every page keeps each page's rows, and once the count passes the bound it
refuses that answer and every later one. A refused answer is never handed
on; an error answer keeps its status; a write's refusal says the install
accepted the write first.
- The count does not stop at a syntax error: a body the relay cut short, or
one that is not JSON, is counted to its end, and the count is never less
than what json.Unmarshal, a Decoder loop or Decoder.Token build from any
bytes (FuzzJSONValuesOverBoundsEveryDecoder; 3.5M inputs locally).
- runMCPCall sets the bound for a hosted run (80,000 values) and refuses it
as a validation error that says how to ask for less and what the tool
takes. It also bounds a call's arguments at 1,000 values: each item of a
list argument becomes a flag, and 131,000 landing_page_code offers in
256 KiB grew an engine to 588 MiB.
- `p202 mcp` on your own computer is unchanged.
80,000 is measured on the wasm engine. Compact real answers within 1 MiB
hold at most about 78,500 values (breakdown rows of zero traffic; report
rows 62,600, click rows 57,500, campaigns 31,400): 60,000 refused 1 MiB of
report rows on 36 of the 37 read tools. Under 80,000 the worst shapes left
an engine at 38 MiB, 54.6 on report_timeseries; 100,000 left 44 and 64 and
admits no real answer 80,000 refuses.
The new tests run in the js/wasm job too (TestHostedEngineBoundsWhatACallDecodes
through the engine's own entry).
Claude-Session: https://claude.ai/code/session_01NdpvkiQugB24uxzUoVGxZC
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
Review finding on #281: the bound withheld answers but not requests. A command that carries on past a failed step kept sending: campaign_delete with several ids deleted every remaining id and refused each answer. Once a call's answers pass the bound, sendRefusal now stops the client sending anything more (data requests, the capabilities and version reads), with the refusal naming the request that was not sent. The answer that passed the bound still says a write was accepted before it was refused. This also closes the two cases that hung on a later answer: a small error answer after the latch losing its message and category, and a fallback taken on "capability not supported" after the capabilities answer was refused. Neither can now send a request. TestHostedCallSendsNothingAfterTheBound runs the delete loop through `mcp call`; the value-bound tests reset the flags they parse when they end. Claude-Session: https://claude.ai/code/session_01NdpvkiQugB24uxzUoVGxZC
|
Answers to the adversarial review of cf487c4 (run 40487413165): F3, requests were still sent after the bound latched: fixed in fed894f.
F2, a refused error answer loses its message and the 402 licence category: the latch case is gone with F3.
F1,
With F3's fix, a refused capabilities answer also leaves the call able to send nothing. After a refused version answer, Q4, body readers outside package
Q8, test placement.
All 19 planted defects are caught by a named test. The verification ladder after fed894f, with go and golangci run under go1.25.14: The claude-code-review report for cf487c4 said its agents had not finished, so it reviewed nothing; that run is being re-run. |
…ook first Second review of #281: - A delete of several ids catches each id's error and answers with its own partial failure, which does not wrap the refusal, so the call lost the hint to look before deleting again. runMCPCall now gives a hosted call's error the refusal's hint whenever the call's answers passed the bound (api.AnswerBoundPassed); the error keeps its own category. - The client-wiring tests (TestBound*, TestAnswerBodiesAreReadOnlyByReadAnswer) answered through httptest, which js/wasm cannot listen on, so the js job did not run them. They now answer through http.DefaultTransport, which the client sends through on both platforms, and the js job runs them. - TestHostedRefusalWrappedByTheCommandIsStillTheRefusal holds the normalisation of a refusal a command wraps in another category (a staged write's capability check reports it as a network error). Claude-Session: https://claude.ai/code/session_01NdpvkiQugB24uxzUoVGxZC
|
Answers to the second adversarial review, of fed894f (run 40489025995): 2. A bulk loop's partial failure lost the "look first" hint: fixed in 774b394.
3. The client-wiring tests did not run in the js/wasm job: fixed in 774b394.
1. A string argument a command splits, such as a delete's
Also added:
All 22 planted defects are caught by a named test. The verification ladder after 774b394: |
…al answer says why Third review of #281: - The hint told every write tool the install may have made the change, also when nothing was sent (a staged write whose capability read was refused). The client now records when the refused answer was to a request that changes data (not a dry run, not a read sent as POST), and the call's hint is one of three: look before calling the tool again (a change was made), nothing that changes data was sent (a write tool refused on a read), or how to ask for less and what the tool takes (a read). - A command that answers without a read it can do without (whoami's user record) exited 0 with nothing saying the bound was why. The call now adds a note on stderr, which a host shows beside the answer, naming the bound and the next step. Claude-Session: https://claude.ai/code/session_01NdpvkiQugB24uxzUoVGxZC
|
Answers to the third adversarial review, of 774b394 (run 40490721003): 4. A write tool was told the change may have been made even when nothing was sent: fixed in 4f6025d.
1. A command that swallows the refusal exits 0 with nothing saying why: fixed in 4f6025d.
2. Refusals cached for the client's life: no change needed.
3.
Q9, the relay as a reader of the new error.
25 planted defects are caught, each by a named test. The verification ladder after 4f6025d: |
#280's traffic source and category update and delete tools sit beside the text ad writes in the catalogue; text_ad_list takes #279's hosted omission of all. The catalogue budget goes to 88,000 characters: #280's tools and the text ad writes together come to about 87,800. The docs keep #281's hosted read bounds, then the text ad reconnect note; the changelogs keep 1.9.79's lines and put the text ad lines after them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
…lue bound When an update sets the type or a link, text_ad_update reads the stored ad before it writes. Under #281's bound an answer past it there stops the call before the PUT, and the refusal says nothing that changes data was sent; the test holds the call to that one GET. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
…start IPN branch
GET /users/{id}/preferences now serves every integration secret as its
stored state (#270), and the PayKickstart IPN keys are one more:
paykickstart_ipn_secret_keys is on ServedPreferences::SECRET, answered
{"stored": true}, and the count this branch served in its place
(paykickstart_ipn_keys_stored, PayKickstartKeys::withoutKeys()) is gone.
GET /system/integrations keys_stored still says how many. The
PreferenceSecretsAreNotServedIntegrationTest round trip stores the keys
with the other secrets.
Conflicts: changelogs (1.9.79's lines, then this branch's), the
preferences docs and the OpenAPI schemas (1.9.79's, with the keys added
as a StoredSecret), and the controller (1.9.79's).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
…or_get answers #281 holds a hosted call's arguments to 1,000 JSON values, because each item of a list argument becomes a flag of its own. rotator_set_rules takes its rules as one JSON document, written to its command as one flag, and has no per-rule tool to fall back on: at 1,000 values a hosted call could send back about 30 rules of one criterion and two destinations, where rotator_get answers about 100 under the result bound. A tool that takes a JSON document is held to 5,000 values instead; at that bound the call allocated 5.5 MB, the list arguments at theirs 5.1. Past it the refusal says what to leave out and which command sends a larger set. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
…JVZoo branch #268 now dates a notification's row by the provider's own time, held to the click for a sale and to the sale for a refund, and brings 1.9.79's #270 (ServedPreferences: GET preferences serves each secret as {"stored": ...}), #280, #281 and #282. Conflicts: both changelogs keep the base's lines with the JVZoo line after them; the OpenAPI preferences update takes #270's oneOf shape, with the JVZoo key's rule in its string branch; the eval cases keep #268's admin-005 notes and add admin-006 after it. jvzoo_ipn_secret_key is already on ServedPreferences::SECRET, and JVZoo adds no other column. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
…79's #270, #280, #281, #282) into the ClickBank branch Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr # Conflicts: # 202-config/changelogs.json # changelogs.txt
Why
The hosted MCP connector runs the p202 CLI built for js/wasm, several engines to an isolate. The hosted relay holds each call to 1 MiB of the install's answers, but an engine's memory follows the number of JSON values it decodes, not the bytes it reads. One MiB of
{"a":0}grew an engine to 83 MiB, and 136.5 MiB onreport_timeseries, which decodes its answer twice. Three such engines end the isolate, and every other call running in it fails too.What changes
The engine now counts values before anything decodes them, and refuses past a bound.
api.readAnsweris the only place the client reads an answer's body. That covers data answers, error answers, and the capabilities and version answers.TestAnswerBodiesAreReadOnlyByReadAnswerholds the package to this: any otherio.ReadAllor.Bodyread fails it.api.LimitAnswerValues, every answer of the call counts toward one bound, because a command that reads every page keeps each page's rows.crosstaband a list with its stats both make several requests.whoami's user record) succeeds with a stderr note saying part of the answer is missing and why.json.Unmarshal, aDecoderloop orDecoder.Tokenbuilds (FuzzJSONValuesOverBoundsEveryDecoder). For a JSON document, it is exactly the values the document holds.runMCPCallsets the bound for a hosted run. The refusal is avalidationerror (exit 1) whose hint says how to ask for less and lists what the tool takes. It reads like the hostedallrefusal.p202 mcpon your own computer: the bounds are set only in a hosted run.p202 searchdecodes no answer and reads no install.The bound: 80,000, from measurement
All numbers below are the wasm engine's linear memory in MiB, measured in Node under the hosted relay's 1 MiB read budget. Repeats vary by 0.5–1.5.
Real answers. Compact JSON as the API sends it, per MiB:
At 60,000, 1 MiB of report rows, zero rows or zero-traffic hours was refused on 36 of 37 read tools. Some tools read more than they print, such as
crosstaband a list with its stats, and those do read that much. At 80,000 and at 100,000, none of these real shapes was refused on any tool.Worst shapes just under each bound. Each shape was swept from 40k to 99.9k values, with repeats. The worst seen at or below each bound:
campaign_list)report_winners,crosstab)report_timeseries(decodes twice)report_timeseriesstays the highest family until its second decode goes. That needs printing without a tree and streaming the timeseries tail, which is a separate change.Before and after, 1 MiB answers. All 37 hosted read tools were run against every shape. These are the tools the earlier measurements listed.
Rmarks the new refusal;x3andx1mark exits the tool already had on that shape, unchanged.{"a":0}[0,…]""values{"a":{}}Across all 37 tools:
whoami: its one data read, the user record, is optional. It answers withuser_name_unavailableand the reason.Tests
internal/api/answer_values_test.go):answer_values_client_test.go):mcp callpath (cmd/mcp_hosted_values_test.go):{"a":0}is refused, prints nothing, and allocates 4.4 MB. A native call on the same answer prints all 130,000 rows and allocates 74 MB.with_stats). Natively the same call succeeds.TestHostedEngineBoundsWhatACallDecodes, js/wasm) runs throughrun(), the relay's entry, with the host's fetch replaced. The js/wasm job runs it, along with the counter tests and the client-wiring tests (TestBound*,TestAnswerBodiesAreReadOnlyByReadAnswer).cmp. Every one was caught by a named test, none by a build error. They were:io.ReadAllWhat ran, and what did not
Verification ladder (
.claude/skills/p202-verify/scripts/verify.sh --changed), scope lines verbatim:Also run:
go test ./...with an emptyHOMEon go1.27.1 and go1.25.14.go testcommands and the js/wasm build, under go1.25.14.golangci-lint2.13.2 under go1.25.14, with 0 issues. CI pins v2.5.0, which was not run locally.Not run: a live install, an agent eval, or anything on the hosted relay or Cloudflare. In Node, under the relay's engine I/O, a refused call leaves the engine with exit 1 and the CLI's usual JSON error on stderr, the shape of every other tool error; the relay's own result shaping was not run.
https://claude.ai/code/session_01NdpvkiQugB24uxzUoVGxZC