Repository navigation
fix(runtime): log a sandboxed business refusal once, at info, without a stack - #26249
Conversation
…a stack A hook refusal the HTTP doors answer in the 4xx band now writes one info line naming the hook or action, its code and the status; the outer action catch no longer repeats a nested refusal its hook already logged. A fault (5xx) keeps the error line with its stack at both catches. The band is read off mapDataError, and every throw is still rethrown. Claude-Session: https://claude.ai/code/session_018jZoq6nbK2HEZ8a7BnDwG7 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jZoq6nbK2HEZ8a7BnDwG7 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check5 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 27 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 09d9884b524a5e34d9c58ebf026aba8176eddf63 && git checkout 09d9884b524a5e34d9c58ebf026aba8176eddf63
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6862ef62abd9062b54e92a8d8f82ec1abf1320c1 a1db083d13509391794f86cdf85103e3c6feaf7f && git checkout -B drift-repro 6862ef62abd9062b54e92a8d8f82ec1abf1320c1 && git merge --no-ff a1db083d13509391794f86cdf85103e3c6feaf7f
node scripts/docs-audit/affected-docs.mjs --json 6862ef62abd9062b54e92a8d8f82ec1abf1320c1 |
席位核对 · ACCEPT ·
|
| item | reading |
|---|---|
| Shape | Draft, base main, Fixes #26153, a patch changeset for @objectstack/runtime, Clause-②: no, Metadata-protocol: none, mergeable clean. |
| Files | 4 files, +327/−10, all on the amended surface: sandbox/body-runner.ts (the two catch sites now call one logBodyThrow), sandbox/quickjs-runner.ts (a module-level WeakSet with isRelayedSandboxRefusal(), not exported from the package index), a new body-throw-log-level.test.ts, and the changeset. |
| Fix | It classifies with mapDataError(err).status, ADR-0112's table that the doors answer with, so there is no second classifier. A 4xx writes one info line naming the hook or action, its code and the status, with no stack. A refusal relayed from a nested body was already logged by that body's catch, so the outer catch writes nothing. A 5xx keeps error with its stack at both catches, as before. Every throw is still rethrown. |
| Evidence | Red first: two error lines with stacks on main for one refused action. The new test pins the band against the /actions door's wire status per shape, plus a fault case that still logs error. The runtime suite passes 457/457 files. 65/65 derived gates exit 0 and --ran reconciles. On this head, 36 check runs succeeded and 11 were expected skips, with 0 failed. |
| Serial | #26170 (domain:spec, not yet dispatched) will touch body-runner.ts later. This diff stays at the catch sites plus one helper, so a later rebase is trivial. |
| Landing | 「按照 pm-dispatch 规则复核过的都授权」 (Leehom, 2026-10-25T11:35Z): ready and auto-merge through the relay. |
Generated by Claude Code
Requested by Leehom · project thread
Fixes #26153
Clause-②: no
Metadata-protocol: none
What changes
A sandboxed hook or action body that refuses on purpose (a throw the HTTP doors answer in the 4xx band, such as hotclm's
422 INVALID_STATE) used to write twoERROR [BodyRunner]lines with full stacks:sandboxed hook threwat the hook's catch, thensandboxed action threwat the action's catch for the same refusal relayed across the VM hop.Now:
info, with no stack. The line is[BodyRunner] sandboxed hook refused(oraction refused) and carriesappId, the hook or action name, the declaredcode, thestatusand the error's message (the debug wrapper, which names every frame). No error object is passed, so no stack.ctx.apiwrite is refused by another hook.errorwith its stack. A crash, a denied capability or a declared 5xx is logged exactly as before. For a nested fault both lines are kept (hook and action), as today.How refusal vs fault is decided
By the status band of
mapDataError(err)(@objectstack/types), the ADR-0112 table the/datadoor answers with and that the bulk-import runner already reuses for its row verdicts. There is no second list of codes or names. The new test pins, for each shape that reaches these catches (nested hook refusal 422, action refusal with no declared status, nested hook crash, action crash, denied capability), that this band equals the band the/actionsdoor actually answers throughHttpDispatcher.handleActions. If the two ever drift, that block goes red.Why
quickjs-runner.tsis touched (claim amendment 6420650570)The fact "this outer
SandboxErroris a nested refusal relayed unchanged" exists only in the runner's pump loop (readRelayedRefusal). The outer error is a new object, sobody-runner.tscould tell it apart only by pattern-stripping.message, which this code forbids. The runner now records such errors in a module-levelWeakSetand exportsisRelayedSandboxRefusal()tobody-runner.ts. It is not exported from the package entry (@objectstack/runtimeexports only., andsandbox/index.tsdoes not re-export it). The catch-site diff stays at the two catches plus one helper,logBodyThrow, so #26170 (same file) rebases trivially.Evidence
main(fbb7599):body-throw-log-level.test.ts, 3 failed / 7 passed. The nested case recordederror '[BodyRunner] sandboxed hook threw'anderror '[BodyRunner] sandboxed action threw', both with stacks. The fault and door-parity cases were already green there.if (isRelayedSandboxRefusal(err)) return;deleted viascripts/ablation-replace.mjs(anchor 1 to 0, blobbfd61dd8ec74to43513b2871d9), only the nested case goes red (1 failed / 9 passed). Restored,git diff HEADempty. The test importssrc/, so nodist/leg applies.a1db083d(rebased ontoorigin/maindf8aac41, which movedmapDataErrorand the/actionscatch for A NOT NULL refusal on an undeclared (drifted) column still answers differently per door: REST 400 VALIDATION_FAILED with a drift hint, dispatcher /data 500 INTERNAL_ERROR, actions 400 VALIDATION_ERROR with the driver's text #26031):pnpm --filter @objectstack/runtime typecheckexit 0 andpnpm --filter @objectstack/runtime test457/457 files, 7422 passed, 19 skipped (lock VERDICT command-exit 0).eslint --no-inline-configon the three touched files, exit 0.node scripts/pm/dispatch-gates.mjs --commandsderived 65 commands on heada1db083d; all 65 run, every one exit 0, and--ranreconciles 65 run, 0 NOT-MEASURED, 0 UNRUN. Families CI runs outside that list (Test Core shards, Build Core, the type-check lanes) are left to this PR's check-runs.Acceptance notes
info, notwarn: a refused click is an ordinary outcome. REST does not log an expected 4xx at all, andwarnwould keep paging anyone alerting on WARN.mapDataError's 4xx arms recognises together with a declared 5xx status is answered 4xx by/databut 5xx by/actions(the divergenceerror-response-structured-arm-door-parity.test.ts§4 already names). Such a throw logsinfo. The reverse case, a 5xx arm code with no declared status (ERR_DATASOURCE_UNAVAILABLE), logserrorwhile/actionsanswers 400, which is the louder direction. Neither shape has a producer in a sandbox body today.Errorout ofUnimplementedScriptRunner(no engine installed) reads 5xx undermapDataError, so it stayserror, which is right for a misconfiguration.sandboxed job threw) is out of this card's scope and unchanged.#26170 remains open: the translation key for hook refusals is not started here.Generated by Claude Code