Repository navigation
fix(runtime): an action or job body refused for a missing capability answers the same coded 403 a hook body does, not a bare 500 - #27045
Conversation
…answers the same coded 403 a hook body does, not a bare 500 One gate, one answer: throwCapabilityDenial no longer sends an action or job origin to throwSandboxFault. The remedy names the body's own owner from the origin kind. The capability check is unchanged. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEkQ5opKoaTFmNRbYvKobt
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEkQ5opKoaTFmNRbYvKobt
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
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 29d8a6a3ae6bf123e4c5c930c9e1b069ac73d057 && git checkout 29d8a6a3ae6bf123e4c5c930c9e1b069ac73d057
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin cb8988ee149ae20fc6017793451098d83d2228a8 af5816664b56471a8dedf5150c419901a3c0bef2 && git checkout -B drift-repro cb8988ee149ae20fc6017793451098d83d2228a8 && git merge --no-ff af5816664b56471a8dedf5150c419901a3c0bef2
node scripts/docs-audit/affected-docs.mjs --json cb8988ee149ae20fc6017793451098d83d2228a8
|
席位核对ACCEPT · The maintainer's standing authorisation for landing reviewed PRs, verbatim: 「按照 pm-dispatch 规则复核过的都授权」.
Generated by Claude Code |
Requested by Leehom · project thread
Fixes #26965
Clause-②: no
Metadata-protocol: none
维护者速读
api.read却调ctx.api.object('x').find)时,POST /api/v1/actions/对象/动作不再回500 Internal server error,而是和 hook 一样回403 INSUFFICIENT_PRIVILEGES,消息点名是哪个 action/job、缺哪个能力,并提示加到「该 action/job 自己的」body.capabilities。error存这句话,error 日志行保留给运维。回滚即 revert 本 PR。packages/runtime/src/sandbox/quickjs-runner.ts的throwCapabilityDenial;action-job-capability-denial-is-a-refusal.integration.test.ts;packages/core/src/utils/import-runner-sandbox-fault-row.test.ts的 §4。pnpm --filter @objectstack/runtime exec vitest run src/sandbox/action-job-capability-denial-is-a-refusal.integration.test.ts,预期 16 项全过(5 个能力 × action / job,加对照)。What changes
@objectstack/runtime,quickjs-runner.ts,throwCapabilityDenial: the first statementif (origin.kind !== 'hook') throwSandboxFault(vm, message);is gone, so every origin's denial crosses the VM as the coded refusal the hook arm already raised (code: 'INSUFFICIENT_PRIVILEGES',status: 403). The remedy names the owner from the origin, in that one template:Grant it by adding 'api.read' to the action's body.capabilities.(orhook's,job's). No per-origin builder; the two constants lose theirHOOK_prefix (CAPABILITY_REFUSAL_CODE/_STATUS, module-private). The capability check itself is untouched.SandboxError.innerMessage,SANDBOX_FAULT_PROP,ErrorInfo.sandboxFault, the pump loop's fault branch) now say it is a refusal for every origin; the fault marker keeps the other host-side faults.capability-denial-is-a-fault.test.tsis renamedcapability-denial-is-a-refusal.test.tsand its pin flipped: each in-VM gate under an action origin hands the classifierinnerMessage= the refusal sentence (nothrew:wrapper, noSandboxError:prefix),code: INSUFFICIENT_PRIVILEGES,status: 403; one new case for a job origin. The "denial the body catches and rethrows" and rejection-side cases are unchanged.action-job-capability-denial-is-a-refusal.integration.test.ts(16): a realObjectQLonSqlDriver(better-sqlite3), the realQuickJSScriptRunner, the realactionBodyRunnerFactory/jobBodyRunnerFactory, and the real/actionsdoor (HttpDispatcher.handleActions). Per token: the action answers403 INSUFFICIENT_PRIVILEGESnaming action, capability and remedy, nothing written, oneinforefusal line and noerrorline; the job rejects with the same coded refusal and keeps itserrorline; controls with the capability run.nested-hook-refusal-is-a-rejection.test.ts: the "capability denial inside the action body is still a fault" case is flipped to the403through the real door.actions-fault-vs-rejection.test.tsand three docblocks: wording only (a timeout is still the sandbox's own fault; a capability denial is no longer named as one).@objectstack/coretest:repo):import-runner-sandbox-fault-row.test.tsis migrated to the new rule, still derived from the producer and with no hand-written site list. ThethrowCapabilityDenialcall sites leave the fault population (thecapability denialfault kind is removed, and §1 now asserts no fault site reads as a capability denial). The helper is read from its body: it must not reachthrowSandboxFault, itsnew Error(…)template is rendered per gate, and itscode/statusare read from theObject.assign(resolving the module constants). New §4 renders every gate under each origin kind (hook,action,job) and holds the door and the import row.action-capability-refusal.dogfood.test.ts(2) through the real stack'sPOST /api/v1/actions/acr_note/ACTION.@objectstack/runtimepatch.Why
Triage ruling A on #26965 (
6456639416): one gate, one answer. After #26963 a hook body's capability denial answered403 INSUFFICIENT_PRIVILEGESnaming the hook and the capability, while an action body's same call answered500 INTERNAL_ERROR"Internal server error", telling neither the caller nor the author which action or which capability. The #4431 argument (an action's own denial is a bug for the operator) was set aside for hooks on #26903 and nothing in it is specific to hooks. The operator signal stays server-side: an action's refusal is logged once atinfoby the BodyRunner ([BodyRunner] sandboxed action refused, with code and status) and the/actionsdoor'sconsole.errorline keeps the full wrapper; a job keeps itserrorline.Acceptance notes
{ ok: false, action: 'failed', error: SENTENCE (the refusal sentence), code: 'INSUFFICIENT_PRIVILEGES' }. Before, the action-origin rendering the census held readerror: 'Internal server error',code: 'IMPORT_ROW_FAILED'. Thectx.api.object('OBJ')gate names the import's own object, and the door answers it403, not the unknown-object heuristic's404(mapDataErrorreads the refusal's code before any message limb). An import fires hooks only, so in practice this is the hook row fix(runtime): a hook refused for a missing capability answers a coded 403 naming the hook and the capability, not a bare 500 #26963 already produced; the census now pins it.DbJobAdapterstoreserr.messageas the run'serror(db-job-adapter.ts,finishRun(runId, 'failed', …)), which now readsjob 'J' threw: SandboxError: capability 'X' not granted to job 'J'. Grant it by adding 'X' to the job's body.capabilities.The BodyRunner's[BodyRunner] sandboxed job threwline stays aterror.Clause-②: no, measured. No call that ran before is refused: the denied call was refused before and is refused now (the gate is the same code path), and every control with the capability passes. What moves is the answer for an already-refused call:500 INTERNAL_ERRORbecomes403 INSUFFICIENT_PRIVILEGES, and the log-only.messagegains theaction 'X' threw:wrapper the refusal relay uses.quickjs-runner.ts):git merge-tree --write-treeof this head with its heada1dbd6eaexits 0 with no conflict. Its catalogue resolver is hook-only and passes host-relayed errors through, so an action or job denial does not meet it.packages/types/src/data-error-classification.ts(~786–792, ~1382),packages/core/src/utils/import-runner.ts~522,packages/rest/src/error-response.ts~708,external-datasource-routes.ts~450,package-routes.ts~239,sharing-routes.ts~429.packages/rest/src/rest-write-route-hook-refusal-sentence.ledger.test.tskeeps a hand-builtsandboxOwnFault("capability 'api.read' not granted …")row; it still passes because it pins the classifier on a synthesised shape, which the producer no longer emits for a capability denial. Comments and a synthetic row only, no behaviour; not touched here (carrier: none).capabilitiesin the Studio editor, and a hook refused for a missing capability answers the client 500 INTERNAL_ERROR instead of a located 4xx #26726's, not this card's.Verification
At head
af5816664b, branched fromorigin/main4bce21da(which carries #26963 ase46e25c0). Every build and test ran underos-verify-lock.4bce21da): 10 failed, 6 passed of 16. The 10 are exactly the five action and five job denials; the action arm answered{"success":false,"error":{"code":"INTERNAL_ERROR","message":"Internal server error","httpStatus":500}}: expected 500 to be 403, the job armexpected undefined to be 'INSUFFICIENT_PRIVILEGES'. The six controls passed.scripts/ablation-replace.mjsput the removedif (origin.kind !== 'hook') throwSandboxFault(vm, message);back, runtime rebuilt,ablation-dist-preflightconfirmed the marker indist/; restored to the HEAD blobe31f4067,git diff HEADempty, rebuilt, preflight--absentconfirmed):capability-denial-is-a-refusal.test.ts,nested-hook-refusal-is-a-rejection.test.ts): 16 failed, 13 passed of 29; the 16 are every denial case.expected 500 to be 403, the bodyInternal server error); restored: 2 of 2.throwCapabilityDenial reaches throwSandboxFault: expected true to be false).pnpm --filter @objectstack/runtime test: 466 files, 7597 passed, 19 skipped.pnpm --filter @objectstack/runtime typecheck: exit 0 (tsc +check:test-typecheckOK).vitest run --project repofor every package with avitest.repo-tests.jsonexcept spec: core 94, objectql 5, rest 280 (18 skipped), runtime 948, types 11, all passed. Spec's repo project (about 16 min) is left to CI's Test Core.pnpm --filter @objectstack/dogfood exec vitest run test/action-capability-refusal.dogfood.test.ts: 2 of 2, after building the dogfood closure. Live log line from it:INFO [BodyRunner] sandboxed action refused {"action":"acr_count_denied","code":"INSUFFICIENT_PRIVILEGES","status":403,...}.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 66 families on this diff; all 66 were run with each exit code captured before any pipe, and all 66 exited 0. Two first exited 3 (PREREQUISITE NOT MET, nothing measured):check-plugin-teardown-shape.mjs --self-teston the shallow clone (fetched its pinned fixture commit, then 48 cases pass) andcheck:dual-build-cjs-loadsbefore a fullpnpm build(built, then 112 entry points across 69 packages load).--ranreconciliation: 66 derived, 66 run, 0 NOT-MEASURED, exit 0.eslint --no-inline-configonpackages/runtime, the census file and the new dogfood file: exit 0.check-commit-card-trailers --range origin/main..HEAD: exit 0.check-platform-checklist --changed-from origin/main: exit 0 (no anchored files).!, no narrowing arm).Human Minimum
packages/specholds no runtime logic.pnpm lintpasses.main, one pull request.packages/spectouched: not touched.content/docs/references/,packages/*/CHANGELOG.mdorcontent/docs/releases/.🤖 Generated with Claude Code
https://claude.ai/code/session_01FEkQ5opKoaTFmNRbYvKobt
Generated by Claude Code