test(rest): measure what a swallowed exec-context resolution reaches, and what the package door answers - #13282
test(rest): measure what a swallowed exec-context resolution reaches, and what the package door answers#13282os-trump wants to merge 2 commits into
Conversation
…context resolution at the package door Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TvqBFLRzXdSPcbusDoED9k
…ad typechecks Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TvqBFLRzXdSPcbusDoED9k
✅ PM review — ACCEPT once CI is green; ⛔ not while it is running (#13255)Card regraded p2 → p1 on this measurement — reasoning on the card, not repeated here. ⛔ No rework is owed; do not push in response to this comment. ✅ The fence held — verified from the change set, not from the reportThe one thing I fenced this dispatch on was ⛔ zero edits to Read from the PR's own file list: exactly one file —
What makes this measurement trustworthyThe card asked a question whose answer is a zero ("is a fault ever served as anonymous access?" / "does it ever surface as the 5xx it is?"). A zero is worthless without a working instrument, and this file builds the instrument first:
|
📓 Docs Drift CheckNothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs. What this run could not see
Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
Part of #13255
A measurement, not a repair. The card asks two questions about the
.catch(() => undefined)on the package door's execution-context resolver — which production inputs reach it, and what the package-management door then answers — and asks explicitly for the answer to come back for grading rather than for a fix to be folded in. One new test file; no production file is edited,packages/rest/src/rest-server.tsincluded (it is under a same-file serial hold by open PR #13264, and the measurement never needed to write to it).Added:
packages/rest/src/package-door-execctx-fault-reachability.test.ts(40 cases).Re-anchoring first
The card's
rest-server.ts:1483is stale by its own admission. Re-anchored by text againstorigin/main:resolveExecCtx(environmentId, req).catch(occurs 16 times in that file; the package door's wrapperresolvePackageRouteExecutionContextis at :1521. Positive control for that grep: the same instrument counts 89 occurrences ofresolveExecCtxin the same file, so a hit is producible.How it was driven
Through the real supplier and the real registrar, never through a stubbed resolver:
RestServeris constructed with its ordinary host-wiring seams (kernelManager,authServiceProvider,objectQLProvider,settingsServiceProvider,requestEnvResolver). No private method is replaced except in one clearly-labelled control.registerPackageRoutesis mounted withresolveExecutionContext: (req) => restServer.resolvePackageRouteExecutionContext(req)— byte-for-byte the wiring inrest-api-plugin.ts:471.sys_user_permission_settosys_permission_settosystem_permissions), not handed to the door ready-made.1. Reachability — the fault classes
Eight classes, each one seam faulted against otherwise-healthy wiring, each paired with the positive control that is the same wiring with the fault removed (which answers 200). Two degrade shapes appear, and they are not the same shape:
KERNEL_BOOT/environments/:environmentId/packageswhose tenant kernel fails to bootUNAUTHENTICATEDAUTH_SERVICE_DOWNAUTH_SERVICE_SYNC_THROWAUTH_API_BUILDgetApi()failsSESSION_STORE_DOWNgetSessionrejectsSETTINGS_PROVIDER_SYNC_THROWPERMISSION_STORE_DOWNuserIdsurvives,systemPermissions: [])FORBIDDENDATA_ENGINE_UNRESOLVABLEisSystemis never synthesised by a degrade, in either shape.The mechanism is one level down from the line the card names
In every class above the private
resolveExecCtxfulfils; it does not reject.computeExecCtxwraps its whole body intry { ... } catch { return undefined; }, and each remaining seam (resolveRequestEnvironmentId,getSession,tryFind) carries its own swallow — so nothing arrives at the wrapper as a rejection and the named.catch(() => undefined)never fires on production input. Control that this is a reading and not a blind instrument: withcomputeExecCtxreplaced by a throwing stub, the same witness reportsrejected, and the wrapper is then observably what absorbs it.⇒ the card's premise holds in its consequence ("a failed resolve is indistinguishable from no context") and is off by one level in its mechanism: the named
.catchis a second net over a first that never lets anything through. Removing it alone would change nothing.2. Consequence — the question the card exists to answer
Is a server-side fault disguised as a permission denial? YES — in two shapes, both pinned by byte-identity against an innocent twin.
JSON.stringify. The caller may hold a valid session; the answer is 401UNAUTHENTICATED, "Authentication is required to access this endpoint."JSON.stringify. The answer is 403FORBIDDEN, "Reading packages requires thestudio.accessorsetup.accesscapability" — an authenticated administrator told they lack a capability, during an outage of the store that holds it.Is a server-side fault ever served as anonymous ACCESS, or as a silent success? NO. Across all 8 classes and all four routes, zero responses carry status 200 and zero carry
success: true. The swallow fails closed. With the anonymous floor isolated (method: 'OPTIONS', an instrument and not a wire path — the registrar mounts no OPTIONS route), the capability clause reads the lost context as a subject holding the empty set and refuses 403, so the refusal is a decision that was reached, not an evaluation that was skipped.Does the fault ever reach the caller as the 5xx it actually is? NO — never, in any class, on any route. That zero is read against a working instrument: this same door, this same harness, answers 500
INTERNAL_ERRORwhen the fault is raised one layer later by the package service. So "no 5xx" is a property of the degradation, not of the harness.⛔ Stated plainly, because the card carries a
securitytopic marker and no verdict: no privilege escalation and no unauthorised access was found or is asserted. What is demonstrated is a downgrade in the diagnostic direction — an outage answered as a denial — and the card's grading is not pre-empted here.3. The repair is deliberately absent
Distinguishing "no context" from "resolution failed" (the latter throwing down the declarative error path) is a behaviour change on a public door. It is out of scope by the card's own wording and by the dispatch, and it is judged against Clause ② and the gates as they stand when it comes back. Nothing here repairs, weakens or reroutes any existing behaviour.
Controls, stated as such
Section 1 of the file shows the instrument producing 200 (healthy end to end, capabilities earned through the shipped aggregation), 401 (a genuinely anonymous caller), 403 (an authenticated caller who genuinely holds nothing) and 500 (a service fault) before any zero is read anywhere else in the file. Every fault class carries its own paired control. Every zero in the file is annotated with the control it is read against.
Reverse verification
Predicted before running: mutate
refusePackageRequestso an ABSENT context falls through to a system subject — reading (3), the fail-open posture — and the context-lost family plus the "never served" and "never 5xx" assertions go red, while the grants-lost family, the healthy 200 and the 500 control stay green (the mutation re-meansundefinedonly, and those cases hold a defined context).Observed, at
ea643c86: 13 failed / 27 passed. The 13 are exactly the context-lost consequence rows, the 401 control, the never-5xx sweep, the OPTIONS isolation, the context-lost byte-identity, the never-served sweep and the sync-throw case.PERMISSION_STORE_DOWN,DATA_ENGINE_UNRESOLVABLE, the 200/403/500 controls and everyresolveExecCtx-fulfils reading stayed green. Restored: 40 passed.Mutation and restore were proven on disk, not by exit codes:
git rev-parse HEAD:packages/rest/src/package-routes.ts=28744f56c6a27e90274adf2d8be1e2bb9f37bfd3; mutated blobefc393d2ad9f4d417dd881024c0ee1d91fe7cd4cwith both injected markers counted present and the original anchor counted absent; restored blob back to28744f56...withgit diff HEADempty and the marker count back to 0. The mutation script carries atrap ... EXIT INT TERMrestore with absolute paths. No build step is involved and none is claimed: the mutated module is same-package source, loaded directly by vitest.Measured but deliberately NOT pinned
A request header that Web
Headersrejects (a space or colon in the name, a non-ASCII name, CR/LF in a value) makes the whole context vanish for a caller holding a valid session — driven, degrade confirmed. It is not in the class table because its production reachability is not established: Node's HTTP parser rejects those shapes before application code sees them, and pinning a class I cannot show is reachable would put a false reading in a file whose whole point is measured ones.Verification, at
ea643c86pnpm --filter @objectstack/rest exec vitest run --maxWorkers=2 src/package-door-execctx-fault-reachability.test.ts—Test Files 1 passed (1)/Tests 40 passed (40)pnpm --filter @objectstack/rest exec vitest run --maxWorkers=2(whole package) —Test Files 159 passed (159)/Tests 2663 passed (2663)pnpm --filter @objectstack/rest typecheck—check:test-typecheck: OK. The new file is inside the checked program, verified rather than assumed:tsc -p tsconfig.test.json --listFilescounts it 1 time (control: the siblingpackage-door-execctx-fault-reading.test.tsalso counts 1).node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(change set read from the merge base by the script itself, not from a two-dot diff) and run:check:nul-bytes,check:cross-package-test-inputs(andscripts/check-cross-package-test-inputs.mjs),check:test-source-alias,check:engine-double-contract,check:where-matcher,check:objectql-double-limit,check:query-options-erasure,check:dispatcher-error-vocabulary,check:logger-receiver-detach,check:page-declaration-shape,check:published-files,check:slot-lookup,check:type-source-resolution,check:type-check-coverage,check:dual-build-cjs-loads,check-ci-filter-parity,check-comment-mask-adoption,check-keyed-text-bounds,check-plugin-teardown-shape,check-shard-attestation,check-undeclared-dep-imports,docs-audit/check-affected-docs,docs-audit/check-drift-comment— all exit 0.pnpm check:type-check-debt— refused first with its own PREREQUISITE text (unbuilt closure). Closure built (turbo run build --concurrency=2 --filter='./packages/*' --filter='./packages/*/*', 70/70 successful) and re-run:OK — 30 ledger entr(ies) re-measured, none above its recorded number.pnpm lint(eslint . --no-inline-config, whole repo, not narrowed) — exit 0.node scripts/check-test-completeness.mjsexits 3 withPREREQUISITE NOT MET — this gate grades a saved turbo run test log, and no log was named. Its own text says this branch is unreachable in CI and is to be recorded as NOT MEASURED locally.Changeset
None, deliberately. This PR adds one test file and changes no shipped behaviour and no published artifact —
packages/restpublishesdist, andsrc/*.test.tsis not in it. Nothing releases, soskip-changesetapplies.Findings filed, not fixed
Both fell out of this measurement, both are unassigned for triage, neither is touched by this PR:
tryFind's per-read swallow inpackages/core/src/security/resolve-authz-context.ts. It travels through neither the.catchthis card names norcomputeExecCtx'scatch, so a repair aimed at either leaves it exactly as it is.async.Refs
.catch(() => undefined)把执行上下文解析失败静默降级为「无上下文」— 该行为在包管理门上可达什么错误状态,未测 #13255 — the cardresolveExecutionContextseam is TEST-ONLY —resolveExecCtxisprivate asyncbehind.catch(() => undefined), so no production throw leaves it #12537 — the sibling reading at this door, whose file this one extends rather than duplicatesrest-server.ts; not waited on, not worked around, not touchedGenerated by Claude Code