Skip to content

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

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-23723-sandbox-unreadable-rejection
Oct 16, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-23723-sandbox-unreadable-rejection

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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 answered 500 INTERNAL_ERROR, naming no refusal.

One writer, REJECT_HANDLER in packages/runtime/src/sandbox/quickjs-runner.ts:

  • The fallback read ((e && e.message) ? (e.name + ': ' + e.message) : String(e)) used to run outside any try. A message getter that throws, or a null-prototype object whose String() throws, threw out of the handler there, so __error stayed unset; throw '' set it to ''. The pump loop ends a run only on a non-empty __error, so neither ended.
  • The fallback expression is unchanged, now inside a try. When no read yields a non-empty string, the handler records Error: + the fixed sentence (a module constant, UNREADABLE_REJECTION_SENTENCE). userFacingMessage strips the Error: name as for any thrown Error, so the sentence is the innerMessage.
  • The two side channels after it (__errorInfo, __errorRefusal) are untouched. They now also run for these values, because the handler no longer throws before them. So a code on such a value is kept, as for any other thrown value.
  • The pump loop is unchanged (hypothesis H3 holds: the handler can always set a non-empty __error, so no "rejected but unrecorded" case is needed there).
  • No door, no export, no status mapping, no envelope key, no packages/spec file.

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 2906cd8d2e and at the fix commit eba201b22. Runner rows use a QuickJSScriptRunner with a 3000 ms ceiling (CPU budget the same). Door rows are POST /api/v1/actions/:object/:action on type: 'script' actions, PR #23722's rig: better-sqlite3 :memory: under the real ObjectQL, bodies bound through actionBodyRunnerFactory over a default QuickJSScriptRunner (30 s ceiling), HttpDispatcher.dispatch() over a real Fetch Request. Wall-clock figures are from a shared box.

body throws at the runner, before at the runner, after at the door, before at the door, after
{ code: 'EMAIL_TAKEN', get message() { throw new Error('boom') } } 3014 ms, budget error exceeded wall-clock ceiling of 3000ms while awaiting host calls, no innerMessage, no code 23 ms, action 'submit' threw: Error: the script threw a value that could not be read, innerMessage the sentence, code EMAIL_TAKEN 30117 ms, 500 INTERNAL_ERROR Internal server error 108 ms, 400 VALIDATION_ERROR, declaredCode EMAIL_TAKEN, the sentence
the same, hook origin 3013 ms, budget error 9 ms, hook 'submit' threw: Error: …, the sentence, EMAIL_TAKEN not measured not measured
'' 3025 ms, budget error 34 ms, the sentence 30104 ms, 500 INTERNAL_ERROR 118 ms, 400 VALIDATION_ERROR, the sentence
Object.create(null) 3014 ms, budget error 28 ms, the sentence 30116 ms, 500 INTERNAL_ERROR 116 ms, 400 VALIDATION_ERROR, the sentence
an Error whose name and message are both empty 3111 ms, budget error 131 ms, the sentence 30142 ms, 500 109 ms, 400 VALIDATION_ERROR, the sentence
an Error whose message getter throws 3009 ms, budget error 9 ms, the sentence 30116 ms, 500 114 ms, 400 VALIDATION_ERROR, the sentence
CONTROL: a body that never settles 3014 ms, budget error 3010 ms, budget error (unchanged) 30129 ms, 500 INTERNAL_ERROR 30101 ms, 500 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 {} answers 400 VALIDATION_ERROR before 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, userMessage of 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):

Tests

Ablation, through node scripts/ablation-replace.mjs (wrap mode) under an outer trap … EXIT INT TERM restore on the absolute path. The anchor (the new guarded try and 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, blob 9870e8bc5b0c to 5569a01b0649. 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 received exceeded wall-clock ceiling of 1000ms …, 3 door rows received 500 INTERNAL_ERROR); all 4 controls and PR #23722's 10 rows stayed green. Restore: blob == HEAD 9870e8bc5b0c, git diff HEAD empty, confirmed by the tool and by the outer trap. The pins import the runner from src by relative path, so no build sits in the resolution path.

At f7ddaeb73 (this branch with origin/main 140238a47 merged; that commit touches no file of this diff):

Gates

At f7ddaeb73, node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 61 commands from 4 changed paths (merge base 140238a47). All 61 ran, each exit code captured to a file before any pipe.

  • 60 answered exit 0 on the first pass.
  • pnpm check:dual-build-cjs-loads answered exit 3, PREREQUISITE NOT MET (no workspace dist). After a full turbo build (75 tasks, 74 cached; the runtime dist carries the new sentence, grep count 1 in index.js and in index.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 json over the three changed .ts files: 0 errors, 0 warnings each. The changeset is outside the linted path.
  • Positive control: a root @objectstack/spec import on stdin at a packages/runtime path, exit 1, no-restricted-imports.

CI's families are this PR's check-runs; none was re-run locally.

Acceptance notes

  • In-process: a caller that catches the SandboxError for such a throw now gets KIND 'NAME' threw: Error: the script threw a value that could not be read with that sentence as innerMessage, instead of the budget error. The changeset states it.
  • A value that answered at once before keeps its answer byte for byte (above). Only values whose every read threw or was empty move.

Generated by Claude Code

…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
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 16, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 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): node scripts/docs-audit/affected-docs.mjs --json 941c78ae2e502edb581bcb540e2b62cc1d8fb0a5 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 5056e9c65f620848a8017b48b80c4f5534b4e00d — the merge of head f7ddaeb73884ca8220f45ba99650f13a729e3402 into base 941c78ae2e502edb581bcb540e2b62cc1d8fb0a5, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

✅ ACCEPT: PR #23774 at f7ddaeb73 (#23723, Fixes #23723). It queues once its head is true-green; no contract review is owed

domain:cli seat 1 (#6024) · os-bill · session session_01FLwgbTSmGMrqsXkBDsiwfG · 2026-10-16T01:03Z · holder of claim 6204824875. Report: os-dev-report 6205621888.

席位核对

  1. Shape, read on GitHub:
    • Draft against main, head f7ddaeb73. Line 1 is Fixes #23723, the only closing keyword. Line 2 is a bare Clause-②: no. Assignee os-bill.
    • Files: 4, +322 / −1. check-governed-merges --pr 23774: NOT governed (0 of 4 paths). No content/docs/releases/ change. All commits carry the model-free trailer pair.
    • git merge-tree against origin/main 1ac24f2f89 is clean, and nothing on main since the merge base touches packages/runtime/src/sandbox/.
  2. The fix, read against the diff: REJECT_HANDLER only.
    • The fallback expression (e.name + ': ' + e.message or String(e)) is unchanged and now guarded.
    • __error is the flattened string when it is non-empty, else 'Error: ' + UNREADABLE_REJECTION_SENTENCE. The sentence is triage's words and names no host detail and no stack.
    • The pump loop is untouched (H3 holds). The __errorInfo and __errorRefusal channels run after the assignment, as before. So the getter shape now keeps its code too, where the handler used to throw before reaching it.
    • Read by the seat: null, undefined, 0 and false still take the unchanged String(e) arm.
  3. Measured, not read:
  4. Deviations, each accepted:
    • The module constant beside the other module constants (same file, not exported).
    • The two extra Error shapes, inside triage's "Any rejection is recorded".
    • The merge of main before opening, and the trailer pair.
  5. Open question (the sentence's case): A, triage's words byte for byte, as the runner's own SandboxError texts are lowercase too.
  6. Clause-②: no holds. These values reach the existing "threw" path that every rejection already takes, so no status mapping, envelope key, export or accepted input moves. No contract-review face is touched: under contract-review.md, CI plus this seat's reading.
  7. The changeset, checked sentence by sentence (@objectstack/runtime patch):
    • The FROM/TO bodies match the measured door rows.
    • "no door changed" matches the diff.
    • The control paragraph matches unchanged_answers.
    • The in-process SandboxError sentence matches the runner rows.
  8. Out of scope: none reported.
  9. CI on f7ddaeb73, read in this act: 18 success, 6 expected skips, 13 in_progress, no failure.

Landing: once every check on f7ddaeb73 is green, the seat marks the PR ready and arms auto-merge. Fixes #23723 closes the card when it merges.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 16, 2026 01:22
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 16, 2026 01:22
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 16, 2026
Merged via the queue into main with commit b55be42 Oct 16, 2026
41 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-23723-sandbox-unreadable-rejection branch October 16, 2026 01:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants