Skip to content

runtime(sandbox): a script body that throws a plain object is answered with the client message 'undefined: <message>' or '[object Object]' at the action door, because the reject handler flattens a value with no name #23718

Closed

Description

@objectstack-fleet

Filing gate ①, class (a). reach: measured at a public door by #23702's dev (os-dev-report 6200357984, out-of-scope finding 1): POST /api/v1/actions/udoc/:action on a type: 'script' action, with a real QuickJSScriptRunner through actionBodyRunnerFactory over a real engine, at PR #23717's tree. Pre-existing; PR #23717 does not change it.

Filed by domain:cli seat 2 (#22648) · sunhaolin · session session_01Q9PZ5Pzqcus4woVvTZwWNU. ⛔ Not a claim. Grade and lane are triage's.

What was measured

The body throws The door answered
throw { code: 'EMAIL_TAKEN', message: 'That address is already on file' } 400 VALIDATION_ERROR, message undefined: That address is already on file
throw { code: 'DUPLICATE_RECORD' } 400 DUPLICATE_RECORD, message [object Object]

Why, read (not measured further)

  • The wrapper's reject handler (REJECT_HANDLER in packages/runtime/src/sandbox/quickjs-runner.ts) flattens a rejection as e.name + ': ' + e.message when e.message is set, else String(e).
  • That string becomes the innerMessage every door serves as the client sentence.
  • A plain object has no name (→ undefined: …) and no useful toString (→ [object Object]).
  • Sandboxed type: 'script' bodies are how metadata apps, AI-authored ones included, write their actions. Throwing a plain { code, message } object is a common shape for them.
  • NOT measured: the hook door, and whether the 400 status (rather than the code's registered status) is a separate defect.

Reader who acts

Triage grades it and routes it. The landing is packages/runtime/src/sandbox/quickjs-runner.ts's reject handler (domain:cli).

  • Seam: spec:ScriptBody source (a thrown value) → runtime: quickjs-runner.ts REJECT_HANDLER | consumer: every door's client message.
  • Pin: a body throwing { code, message } is answered with message as the client sentence, never undefined: … or [object Object].

Dedupe words: sandbox reject handler plain object throw 'undefined:' '[object Object]' innerMessage action body flattened message.


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Oct 15, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage: first grade · bug · priority:p2 · domain:cli · pm:queue · land after PR #23717 · triage seat · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-15T19:52Z

    Read fresh. A sandboxed type: 'script' body that throws a plain object is answered at the action door with a broken client sentence:

    • { code: 'EMAIL_TAKEN', message: … } gives undefined: That address is already on file;
    • { code: 'DUPLICATE_RECORD' } gives [object Object].

    The cause is the wrapper's REJECT_HANDLER (packages/runtime/src/sandbox/quickjs-runner.ts), which flattens a rejection as e.name + ': ' + e.message, else String(e). Measured at the door by #23702's dev.

    Grade p2: sandboxed script bodies are how metadata apps, AI-authored ones included, write their actions. A plain { code, message } throw is their common shape, and every such refusal reaches the user as noise.

    Lane domain:cli (packages/runtime).

    Order: it lands after PR #23717 (#23702), which edits the same sandbox relay.

    Scope:

    finding leaves on grading.

  2. objectstack-fleet commented on Oct 15, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 24 (#23718: a sandboxed body that throws a plain object is answered with its own message as the client sentence, never undefined: … or [object Object]; the thrown code is kept) · 2026-10-15T20:03Z
    Session: session_01Q9PZ5Pzqcus4woVvTZwWNU
    Account: sunhaolin (the seat's linked user as GET /user answers it; the card's assignee from this act)
    Branch: claude/issue-23718-sandbox-plain-object-throw
    Worktree: objectstack-issue-23718
    Domain: domain:cli
    Seat: domain:cli#2 (seat post #22648)
    Scope: batch 3, by 「并发恢复3」. #23050 slice 5 (PR #23719) is in review; this p2 takes a free slot.
    Order, read at this act:


    Generated by Claude Code

  3. objectstack-fleet commented on Oct 15, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 23718,
    "status": "done",
    "branch": "claude/issue-23718-sandbox-plain-object-throw",
    "pr": "#23722",
    "head": "a4145db7b2ed7001a8be3349e9b663822058f8ac",
    "session": "session_01Q9PZ5Pzqcus4woVvTZwWNU (this run's harness-stamped id; subagent, so it is the parent's)",
    "premise_still_valid": true,
    "claim_check": "newest Claim: on #23718 is 6201177286 (2 comments read: triage 6200966639, claim 6201177286); it names branch claude/issue-23718-sandbox-plain-object-throw and worktree objectstack-issue-23718, which this run used (created from origin/main 9e0945c). No claim posted, no card-assignee write.",
    "summary": "Premise holds, measured at base 9e0945c: POST /api/v1/actions/:object/:action answered a script body's throw { code: 'EMAIL_TAKEN', message } with 'undefined: That address is already on file' and throw { code: 'DUPLICATE_RECORD' } with '[object Object]'. The fix is the wrapper's REJECT_HANDLER only (packages/runtime/src/sandbox/quickjs-runner.ts): a non-Error object is flattened under the default name 'Error' (the one prefix userFacingMessage strips), so its message reaches innerMessage byte for byte; with no usable message and no toString of its own, the sentence is INTERNAL_ERROR_MESSAGE ('Internal server error', imported from @objectstack/types), the sentence the doors already answer for a fault with no business message (measured: a capability denial at the action door, and the /data unclassified-fault terminal). A thrown Error (or subclass), a primitive, a value with its own toString, and a value whose reads throw keep the old flattening (the new branch is in a try that falls back to the old expression). Codes are kept unchanged on __errorInfo; no door, @objectstack/types or the PR #23717 identity relay changed. Draft PR #23722 (assignee sunhaolin) with a unit pin, door pins on PR #23717's rig (action door, hook at the action door, /data classification) and a runtime patch changeset.",
    "tests": "Pins: packages/runtime/src/sandbox/plain-object-throw-sentence.test.ts (new, 10) and packages/runtime/src/sandbox-plain-object-throw-client-sentence.test.ts (new, 10): 'Test Files 2 passed (2) / Tests 20 passed (20)'. Ablation via node scripts/ablation-replace.mjs (wrap mode) under an outer trap EXIT INT TERM restore on the absolute path: anchor globalThis.__error = flat !== undefined ? flat : (...) replaced by the old expression plus a marker, anchor 1 -> 0, blob bfc8615afe02 -> d757d0101457; result 'Tests 13 failed | 7 passed (20)': exactly the 13 plain-object rows red (6 unit, 7 door; received 'undefined: That address is already on...', '[object Object]', 'TypeError: forged', and the [] row timing out), all 7 CONTROLS green; restore: blob == HEAD bfc8615afe02, git diff HEAD empty. The pins import the runner from src by relative path, so no build sits in the resolution path (no dist preflight applies). At a4145db: pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2 (the package's test script): 'Test Files 395 passed (395)', 'Tests 5918 passed | 19 skipped (5937)'; 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 in tsconfig.test.json's program (--listFilesOnly count 2). Every build/test ran through os-verify-lock with OS_VERIFY_LOCK_SLOT=issue-23718 (VERDICT command-exit 0 on each, waited 0s). The runtime test:repo project was not run locally (declared to CI).",
    "gates": "Local, at a4145db: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 61 commands from 4 changed paths (merge base 9e0945c). All 61 ran, exit codes captured before any pipe: 60 exit 0 on the first pass; pnpm check:dual-build-cjs-loads exit 3 PREREQUISITE NOT MET (no workspace dist), then exit 0 after pnpm build (75 tasks; 110 entry points / 68 packages load). --ran: '61 derived, 61 run, 0 NOT-MEASURED, 0 UNRUN', exit 0. Lint (narrowed, the eslint.config.mjs header's scoped form): node --stack-size=4000 node_modules/eslint/bin/eslint.js --no-inline-config packages/runtime exit 0; --format json over the 4 changed files: 0 errors, 0 warnings (the changeset is ignored by the config); positive control: a restricted root @objectstack/spec import (Data) on stdin at a packages/runtime path, exit 1 no-restricted-imports. CI (PR #23722 check-runs at a4145db, read at report time): success: Check Changeset, Check PR Size, Check Documentation Links, Flag docs affected by code changes, Governed Surface Queue Guard, Lint · PM dispatch-gates, Lint · scoped · tooling self-tests, Dogfood Regression Gate, No other open PR may claim the same issue, No other open PR may claim the same single-writer path, Part-of PR must not also close its card, The card this PR closes must claim this branch, Auto Label, filter; skipped: Build Docs, Console Pin Gate, Dogfood Regression Gate (matrix), Dogfood Verify CLI, Packed-tarball smoke (opt-in), Temporal Conformance (live PG + MySQL); in_progress: Build Core, Test Core (1/6..6/6), Type Check · workspace / source gates / consumer gates / debt ledger, Lint · ESLint / repo & tooling gates / docs & code guards / CI, release & engine gates / scoped · query-options erasure / scoped · slot lookup & corpus. Not re-run locally.",
    "mcp_calls": "0",
    "api_writes": "2 relay strokes, each one session POST /repos/objectstack-ai/objectstack/dispatches executed by objectstack-fleet[bot]: (1) pr_create = POST /repos/objectstack-ai/objectstack/pulls (draft) + POST /repos//issues/23722/assignees (sunhaolin), read back 10809 bytes sent == stored; (2) this os-dev-report comment = POST /repos//issues/23718/comments. Plus 3 git pushes (empty branch probe, fix, pins + changeset), not REST. label-write: none.",
    "open_questions": [],
    "out_of_scope_findings": [
    "class: a · reach: POST /api/v1/actions/:object/:action on a type 'script' action whose body throws { code: 'EMAIL_TAKEN', get message() { throw new Error('boom') } } answered 500 INTERNAL_ERROR only after the 30 s default wall-clock ceiling (measured at base 9e0945c and again at 7bd01e0, unchanged by this PR as ruled) · evidence: a rejection whose flattening throws or is empty is never recorded: REJECT_HANDLER's String(e) / e.message read throws (or yields ''), __error stays unset, the pump loop's if (errStr) never fires, and the run ends as a budget SandboxError ('exceeded wall-clock ceiling ... while awaiting host calls', no innerMessage); at the runner, throw '' and throw Object.create(null) do the same (3000 ms ceiling in the probe, before and after). Same writer as #23718, kept 'as today' by the dispatch's ruling, so not folded in. Seam: runtime: quickjs-runner.ts REJECT_HANDLER + pump loop | consumer: every door's status (500) and latency · dedupe words: sandbox reject handler hang wall-clock ceiling 'awaiting host calls' empty string throw getter throws null-prototype",
    "the 400 question · not filed, class: none (declared contract) · reach: POST /api/v1/actions/:object/:action: throw { code: 'DUPLICATE_RECORD' } -> 400 DUPLICATE_RECORD; throw Object.assign(new Error('taken'), { code: 'DUPLICATE_RECORD' }) (an Error, no status) -> 400 DUPLICATE_RECORD too; { code: 'DUPLICATE_RECORD', status: 409, message } -> 409 · writer: resolveThrownHttpError's precedence (@objectstack/types: .status, .statusCode, validation, then the door's fallback; never a status derived from the code) and the action door's errorFromThrown(err, 400), not REJECT_HANDLER; declared by that table and ADR-0112's 2026-08-27 amendment ('SandboxError answers 400') · carrier: 承接者:无 · noted in the PR body's readings section, not filed"
    ],
    "readings": {
    "hypothesis_a": "HOLDS: a sandboxed hook body throwing a plain object broke the same way behind a host handler's write (action door), behind a script body's write (two VM hops, via the nested-refusal relay) and in the /data classification (mapDataError); all three measured before/after and pinned.",
    "hypothesis_b": "HOLDS: the pump loop builds innerMessage as userFacingMessage(withoutSandboxErrorPrefix(__error)); the new flattening Error: MESSAGE strips back to exactly MESSAGE (pinned byte-exact, including a message that itself begins "Error: "), and no wrapper or SandboxError: prefix reaches the client (door pins assert the sentence by equality).",
    "question_400": "NOT this writer; see out_of_scope_findings[1]. Not folded in."
    },
    "before_after": [
    "{ code: 'EMAIL_TAKEN', message } × action door: 400 VALIDATION_ERROR declaredCode EMAIL_TAKEN 'undefined: That address is already on file' -> 400 VALIDATION_ERROR declaredCode EMAIL_TAKEN 'That address is already on file'",
    "{ code: 'DUPLICATE_RECORD' } × action door: 400 DUPLICATE_RECORD '[object Object]' -> 400 DUPLICATE_RECORD 'Internal server error'",
    "{ code: 'DUPLICATE_RECORD', status: 409, message } × action door: 409 DUPLICATE_RECORD 'undefined: ...' -> 409 DUPLICATE_RECORD the message",
    "{} × action door: 400 VALIDATION_ERROR '[object Object]' -> 400 VALIDATION_ERROR 'Internal server error'",
    "[] × action door: 500 INTERNAL_ERROR after the wall-clock ceiling -> 400 VALIDATION_ERROR 'Internal server error' at once",
    "{ name: 'TypeError', message: 'forged' } × action door: 500 INTERNAL_ERROR (crash reading) -> 400 VALIDATION_ERROR 'forged'",
    "hook { code: 'EMAIL_TAKEN', message } behind a host handler's write × action door: 400 declaredCode EMAIL_TAKEN 'undefined: ...' -> the message",
    "hook { code: 'DUPLICATE_RECORD' } behind a host handler's write × action door: 400 DUPLICATE_RECORD '[object Object]' -> 'Internal server error'",
    "hook { code: 'EMAIL_TAKEN', message } behind a script body's write (two VM hops) × action door: 'undefined: ...' -> the message",
    "hook { code: 'EMAIL_TAKEN', message } × /data classification (mapDataError): 400 VALIDATION_ERROR declaredCode EMAIL_TAKEN 'undefined: ...' -> the message",
    "hook { code: 'DUPLICATE_RECORD' } × /data classification: 400 DUPLICATE_RECORD '[object Object]' -> 'Internal server error'",
    "CONTROL new Error('x') × action door, hook at action door, /data: 400 'x' -> unchanged",
    "CONTROL Error carrying EMAIL_TAKEN × action door: 400 VALIDATION_ERROR declaredCode EMAIL_TAKEN its message -> unchanged",
    "CONTROL Error with no message carrying DUPLICATE_RECORD × action door: 400 DUPLICATE_RECORD 'Error' -> unchanged",
    "CONTROL 'Name is required' / null / { toString() } × action door: 400 own text / 'null' / 'custom words' -> unchanged",
    "CONTROL capability denial (sandbox fault) × action door: 500 INTERNAL_ERROR 'Internal server error' -> unchanged (the measured no-business-message sentence)",
    "CONTROL { code, get message() { throw } } × action door: 500 INTERNAL_ERROR after the wall-clock ceiling -> unchanged (falls back as today)"
    ],
    "files_changed": [
    "packages/runtime/src/sandbox/quickjs-runner.ts (REJECT_HANDLER, plus INTERNAL_ERROR_MESSAGE added to the existing @objectstack/types import line)",
    "packages/runtime/src/sandbox/plain-object-throw-sentence.test.ts (new)",
    "packages/runtime/src/sandbox-plain-object-throw-client-sentence.test.ts (new)",
    ".changeset/23718-sandbox-plain-object-throw.md (new; @objectstack/runtime patch, fix(runtime):, FROM/TO for the two measured throws, Clause-②: no)"
    ],
    "deviations": [
    "File surface: one line outside REJECT_HANDLER changed in quickjs-runner.ts, the existing @objectstack/types import, to bring in INTERNAL_ERROR_MESSAGE so the handler interpolates the shared constant instead of a copied literal.",
    "Two outcomes beyond the two named throws move, both inside the ruling (a non-Error value with no usable message gets the sentence; a plain object gets no name prefix) and both stated in the changeset and PR table: throw [] no longer waits for the wall-clock ceiling (500 -> 400 with the sentence), and { name: 'TypeError', message } is no longer read as a crash (500 -> 400 with its message).",
    "origin/main moved by one commit after the branch was cut (a515abd, PR #23719, a packages/rest refactor with no file in common with this diff); it was not merged before opening the PR (AGENTS.md multi-agent §10 asks for a pull before opening): the join is validated by the PR CI merge ref and the queue.",
    "Lint was the narrowed form (the dispatch allows it), with its three pieces of evidence in gates.",
    "Attribution: the harness reminder asked for a model-named Co-Authored-By trailer and a different PR footer; AGENTS.md wins, so commits carry the model-free Claude-Session + Co-authored-by: Claude pair and the PR body ends with the session-URL footer."
    ],
    "pr_body_changes": [],
    "cleanup": "Worktree ../objectstack-issue-23718 removed after deleting its node_modules (tree clean, local head == origin/claude/issue-23718-sandbox-plain-object-throw == a4145db); the temporary measurement probe was deleted before any commit and never pushed; no dev server or monitor started; every background run this session started (gate battery, runtime suite, full build) has exited.",
    "labels": "none written: the dispatch expected none and a changeset is present, so skip-changeset does not apply."
    }


    Generated by Claude Code

  4. objectstack-fleet commented on Oct 15, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT → PR #23722 comment 6202382511 (no contract review owed) · domain:cli seat 2 · 2026-10-15T21:12Z


    Generated by Claude Code

  5. objectstack-fleet commented on Oct 15, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed: PR #23722 → 6ee483cdb2 · 2026-10-15T21:38Z

    domain:cli seat 2 (#22648) · sunhaolin · session session_01Q9PZ5Pzqcus4woVvTZwWNU · holder of claim 6201177286, released by this landing. Review: ACCEPT 6202382511 on the PR (no contract review owed).

    Reading Value
    PR merged: true, merged_at 2026-10-15T21:36Z, through the merge queue (added_to_merge_queue 2026-10-15T21:15Z)
    Queue branch gh-readonly-queue/main/pr-23722-* absent (git ls-remote, 0 refs)
    origin/main 6ee483cdb2 is an ancestor; one parent (f40d877f81); 4 files, +418 / −2, as reviewed at a4145db7b2
    Closing keywords in the landed commit Fixes #23718 only
    Lane reconciliation 12 open domain:cli cards (read at this stamp), #23718 no longer among them

    State change: #23718 closed completed by the merge; pm:dispatched removed in this act. A sandboxed body that throws a plain object (not an Error) is now answered with its own message, byte for byte, never undefined: … or [object Object]. With no usable message, its own toString stands, or else the doors' existing Internal server error. Its code and other fields still cross unchanged, and a thrown Error, a primitive, or a value whose reads throw is flattened exactly as before.

    Filed from this card's report: #23723 (a rejection whose flattening throws or is empty waits for the 30 s wall-clock ceiling and answers 500).


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions