Repository navigation
fix(runtime): a sandboxed body whose rejection no read can flatten ends its run at once with a fixed sentence, instead of at the wall-clock ceiling (#23723) - #23774
Conversation
…can flatten A sandboxed body that throws '' , a null-prototype object, or an object whose message getter throws left __error unset or empty, so the pump loop never saw the rejection and the run waited for the wall-clock ceiling (30 s at the action door, then 500 naming no refusal). The handler now guards its fallback read and records the fixed sentence "the script threw a value that could not be read" when no read yields a non-empty string. Every value a read can flatten keeps its flattening byte for byte. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FLwgbTSmGMrqsXkBDsiwfG
…at once
Runner rows (throw '', a null-prototype object, a message getter that
throws, on both wrappers) and door rows at POST /api/v1/actions/:object/:action,
each on a 1000 ms wall ceiling so a named refusal is an answer within a
second by construction. Controls: a readable { code, message } keeps its
answer, and a body that never settles still ends at the ceiling.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FLwgbTSmGMrqsXkBDsiwfG
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FLwgbTSmGMrqsXkBDsiwfG
…ndbox-unreadable-rejection
📓 Docs Drift Check1 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 — 26 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 5056e9c65f620848a8017b48b80c4f5534b4e00d && git checkout 5056e9c65f620848a8017b48b80c4f5534b4e00d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 941c78ae2e502edb581bcb540e2b62cc1d8fb0a5 f7ddaeb73884ca8220f45ba99650f13a729e3402 && git checkout -B drift-repro 941c78ae2e502edb581bcb540e2b62cc1d8fb0a5 && git merge --no-ff f7ddaeb73884ca8220f45ba99650f13a729e3402
node scripts/docs-audit/affected-docs.mjs --json 941c78ae2e502edb581bcb540e2b62cc1d8fb0a5 |
✅ ACCEPT: PR #23774 at
|
Fixes #23723
Clause-②: no
What changed
A sandboxed body whose rejection no read can flatten now ends its run at once, on the same "threw" path as any other rejection, with the fixed sentence
the script threw a value that could not be read. Before, it waited for the wall-clock ceiling (30 s at the action door) and the door answered500 INTERNAL_ERROR, naming no refusal.One writer,
REJECT_HANDLERinpackages/runtime/src/sandbox/quickjs-runner.ts:(e && e.message) ? (e.name + ': ' + e.message) : String(e)) used to run outside anytry. Amessagegetter that throws, or a null-prototype object whoseString()throws, threw out of the handler there, so__errorstayed unset;throw ''set it to''. The pump loop ends a run only on a non-empty__error, so neither ended.try. When no read yields a non-empty string, the handler recordsError:+ the fixed sentence (a module constant,UNREADABLE_REJECTION_SENTENCE).userFacingMessagestrips theError:name as for any thrownError, so the sentence is theinnerMessage.__errorInfo,__errorRefusal) are untouched. They now also run for these values, because the handler no longer throws before them. So acodeon such a value is kept, as for any other thrown value.__error, so no "rejected but unrecorded" case is needed there).packages/specfile.The sentence names nothing the value carried (the getter's own error is not read into it) and no stack.
Before / after, measured
Rig: a temporary probe (deleted before any commit), run at base
2906cd8d2eand at the fix commiteba201b22. Runner rows use aQuickJSScriptRunnerwith a 3000 ms ceiling (CPU budget the same). Door rows arePOST /api/v1/actions/:object/:actionontype: 'script'actions, PR #23722's rig: better-sqlite3:memory:under the realObjectQL, bodies bound throughactionBodyRunnerFactoryover a defaultQuickJSScriptRunner(30 s ceiling),HttpDispatcher.dispatch()over a real FetchRequest. Wall-clock figures are from a shared box.{ code: 'EMAIL_TAKEN', get message() { throw new Error('boom') } }exceeded wall-clock ceiling of 3000ms while awaiting host calls, noinnerMessage, nocodeaction 'submit' threw: Error: the script threw a value that could not be read,innerMessagethe sentence,codeEMAIL_TAKEN500 INTERNAL_ERRORInternal server error400 VALIDATION_ERROR,declaredCodeEMAIL_TAKEN, the sentencehook 'submit' threw: Error: …, the sentence,EMAIL_TAKEN''500 INTERNAL_ERROR400 VALIDATION_ERROR, the sentenceObject.create(null)500 INTERNAL_ERROR400 VALIDATION_ERROR, the sentenceErrorwhosenameandmessageare both empty500400 VALIDATION_ERROR, the sentenceErrorwhosemessagegetter throws500400 VALIDATION_ERROR, the sentence500 INTERNAL_ERROR500 INTERNAL_ERROR(unchanged)The door's status for these values is the one it already gives a body that threw a value with no registered code (
throw {}answers400 VALIDATION_ERRORbefore and after). It was measured, not changed.The last two value rows are not in the card. They are the same defect (a read that throws, or a
String()that is empty), found while reading the handler, and they move inside triage's scope ("Any rejection is recorded, even when reading it throws or yields''").Unchanged answers, byte-compared
The probe recorded every value the handler could already read, at the runner (
ctor,name,message,innerMessage,code,status,fields,userMessageof the error) and at the door (status and full response body), before and after. 39 of 39 rows are byte-identical (18 runner rows, 21 door rows):{ code, message },{ code },{ code, status: 409, message },{},[],{ name: 'TypeError', message }, amessagestartingError:, an owntoString;Error,TypeError, anErrorcarrying a code, anErrorwith no message carrying a code;__errorInfochannel (code,fields,status: 422,userMessage);null,undefined,0,false;409 UNIQUE_VIOLATIONwithdetails), a nested hook refusal's sentence through the__errorRefusalchannel (400 VALIDATION_ERRORx), and a capability denial (500 INTERNAL_ERROR).Tests
packages/runtime/src/sandbox/unreadable-rejection-recorded.test.ts(new, 8): the three shapes, the getter shape on the hook origin, the two extraErrorshapes; CONTROLS:{ code, message }keeps PR fix(runtime): a sandboxed body's thrown plain object is answered with its own message, never 'undefined: …' or '[object Object]' (#23718) #23722's answer, and a never-settling body still ends at the ceiling with noinnerMessage.packages/runtime/src/sandbox-unreadable-rejection-door.test.ts(new, 5): the three shapes at the action door (status,code,declaredCode, sentence); CONTROLS:{ code, message }keeps PR fix(runtime): a sandboxed body's thrown plain object is answered with its own message, never 'undefined: …' or '[object Object]' (#23718) #23722's answer, and a never-settling body still answers500 INTERNAL_ERROR.Ablation, through
node scripts/ablation-replace.mjs(wrap mode) under an outertrap … EXIT INT TERMrestore on the absolute path. The anchor (the new guardedtryand the last-resort assignment, 4 lines) was replaced by the base's one-line assignment plus a marker: anchor 1 to 0, marker 0 to 1, blob9870e8bc5b0cto5569a01b0649. Run over the two new files plus PR #23722's unit pin:Tests 9 failed | 14 passed (23). Exactly the 9 shape rows went red (6 unit rows receivedexceeded wall-clock ceiling of 1000ms …, 3 door rows received500 INTERNAL_ERROR); all 4 controls and PR #23722's 10 rows stayed green. Restore: blob == HEAD9870e8bc5b0c,git diff HEADempty, confirmed by the tool and by the outer trap. The pins import the runner fromsrcby relative path, so no build sits in the resolution path.At
f7ddaeb73(this branch withorigin/main140238a47merged; that commit touches no file of this diff):pnpm --filter @objectstack/runtime test:Test Files 397 passed (397),Tests 5931 passed | 19 skipped (5950).pnpm --filter @objectstack/runtime test:repo:Test Files 3 passed (3),Tests 808 passed (808).pnpm --filter @objectstack/runtime typecheck: exit 0;check:test-typecheck: OK — 27 file(s) / 190 error(s) / 68 pinned signature(s) held. Both new test files are intsconfig.test.json's program (--listFilesOnly, count 2).e02875aba(fix and pins, before the changeset): the two new files with PR fix(runtime): a sandboxed body's thrown plain object is answered with its own message, never 'undefined: …' or '[object Object]' (#23718) #23722's and PR fix(runtime): the sandbox relay keeps the engine's DuplicateRecordError, so a script body's uncaught duplicate answers 409 UNIQUE_VIOLATION (#23702) #23717's pins by path, plus the temporary probe:Test Files 7 passed (7),Tests 51 passed (51).Gates
At
f7ddaeb73,node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 61 commands from 4 changed paths (merge base140238a47). All 61 ran, each exit code captured to a file before any pipe.pnpm check:dual-build-cjs-loadsanswered exit 3,PREREQUISITE NOT MET(no workspacedist). After a full turbo build (75 tasks, 74 cached; the runtimedistcarries the new sentence, grep count 1 inindex.jsand inindex.cjs) it answered exit 0:110 published require entry point(s) across 68 package(s) load.--ran:61 derived, 61 run, 0 NOT-MEASURED, 0 UNRUN, exit 0.Lint, the lane's blind spot, narrowed to the edited package:
node --stack-size=4000 node_modules/eslint/bin/eslint.js --no-inline-config packages/runtime: exit 0.--format jsonover the three changed.tsfiles: 0 errors, 0 warnings each. The changeset is outside the linted path.@objectstack/specimport on stdin at apackages/runtimepath, exit 1,no-restricted-imports.CI's families are this PR's check-runs; none was re-run locally.
Acceptance notes
SandboxErrorfor such a throw now getsKIND 'NAME' threw: Error: the script threw a value that could not be readwith that sentence asinnerMessage, instead of the budget error. The changeset states it.Generated by Claude Code