Skip to content

Commit 6ee483c

Browse files
fix(runtime): a sandboxed body's thrown plain object is answered with its own message, never 'undefined: …' or '[object Object]' (#23718) (#23722)
Fixes #23718 Clause-②: no ## What changed A sandboxed body (a `type: 'script'` action body, or a sandboxed hook body) that throws a value which is not an `Error`, the plain `{ code, message }` object metadata app bodies throw, is now answered with that value's own `message` as the client sentence. Its code is kept, as before. One place changed: the wrapper's `REJECT_HANDLER` in `packages/runtime/src/sandbox/quickjs-runner.ts`, the one writer that flattens a rejection into `__error`. No door changed. `@objectstack/types` is not changed, and PR #23717's identity relay is left as it landed. - The handler flattened every rejection as `NAME: MESSAGE`, else `String(e)`. A plain object has no class name and no `toString` of its own, so the doors served `undefined: MESSAGE`, or `[object Object]` when it had no `message`. - A non-`Error` object is now flattened under the default name `Error`, the one prefix `userFacingMessage` strips, so its `message` reaches `innerMessage` byte for byte. A `name` on a plain object is a field, not a frame, and gets no prefix. - With no usable `message` (a non-blank string) and no `toString` of its own, the sentence is `INTERNAL_ERROR_MESSAGE` (`Internal server error`), read from `@objectstack/types`, never a literal. It is what the doors already answer for a fault with no business message, measured below. A value with a `toString` of its own keeps it. - Unchanged: a thrown `Error` (or subclass), a thrown primitive, and a value whose reads throw. The new branch is inside a `try`; any throw there falls back to the old expression, so a throwing getter behaves exactly as it does today. - `code`, `status`, `fields` and `userMessage` still ride `__errorInfo`, untouched. - One line outside the handler: `INTERNAL_ERROR_MESSAGE` joins the file's existing `@objectstack/types` import, so the handler can interpolate it. ## Before / after, measured Rig: PR #23717's. better-sqlite3 `:memory:` under the real `ObjectQL`, action bodies through `actionBodyRunnerFactory`, hook bodies through `hookBodyRunnerFactory`, both over a real `QuickJSScriptRunner`, and `HttpDispatcher.dispatch()` over a real Fetch `Request`. The `/data` classification is read at function level (`mapDataError`), as PR #23717 read it. "Before" was read at base `9e0945cf45` (and again under the ablation below), "after" at `7bd01e068d`. | thrown value | door | before | after | |---|---|---|---| | `{ code: 'EMAIL_TAKEN', message: 'That address is already on file' }` | action | `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 | `400 DUPLICATE_RECORD`, `[object Object]` | `400 DUPLICATE_RECORD`, `Internal server error` | | `{ code: 'DUPLICATE_RECORD', status: 409, message }` | action | `409 DUPLICATE_RECORD`, `undefined: …` | `409 DUPLICATE_RECORD`, the message | | `{}` | action | `400 VALIDATION_ERROR`, `[object Object]` | `400 VALIDATION_ERROR`, `Internal server error` | | `[]` | action | `500 INTERNAL_ERROR` after the wall-clock ceiling (no rejection was recorded) | `400 VALIDATION_ERROR`, `Internal server error`, at once | | `{ name: 'TypeError', message: 'forged' }` | action | `500 INTERNAL_ERROR` (read as a crash) | `400 VALIDATION_ERROR`, `forged` | | hook body, `{ code: 'EMAIL_TAKEN', message }`, behind a host handler's write | action | `400`, `declaredCode: EMAIL_TAKEN`, `undefined: …` | `400`, `declaredCode: EMAIL_TAKEN`, the message | | hook body, `{ code: 'DUPLICATE_RECORD' }`, behind a host handler's write | action | `400 DUPLICATE_RECORD`, `[object Object]` | `400 DUPLICATE_RECORD`, `Internal server error` | | hook body, `{ code: 'EMAIL_TAKEN', message }`, behind a script body's write (two VM hops) | action | `400`, `undefined: …` | `400`, the message | | hook body, `{ code: 'EMAIL_TAKEN', message }` | `/data` classification | `400 VALIDATION_ERROR`, `declaredCode: EMAIL_TAKEN`, `undefined: …` | the same, with the message | | hook body, `{ code: 'DUPLICATE_RECORD' }` | `/data` classification | `400 DUPLICATE_RECORD`, `[object Object]` | `400 DUPLICATE_RECORD`, `Internal server error` | | CONTROL `new Error('x')` | action, hook at action, `/data` | `400`, `x` | unchanged | | CONTROL `Error` carrying `EMAIL_TAKEN` | action | `400 VALIDATION_ERROR`, `declaredCode: EMAIL_TAKEN`, its message | unchanged | | CONTROL `Error` with no message, carrying `DUPLICATE_RECORD` | action | `400 DUPLICATE_RECORD`, `Error` | unchanged | | CONTROL `'Name is required'` / `null` | action | `400`, `Name is required` / `null` | unchanged | | CONTROL `{ toString() { return 'custom words'; } }` | action | `400`, `custom words` | unchanged | | CONTROL capability denial (a sandbox fault) | action | `500 INTERNAL_ERROR`, `Internal server error` | unchanged | | CONTROL `{ code, get message() { throw … } }` | action | `500 INTERNAL_ERROR` after the wall-clock ceiling | unchanged (falls back as today) | The no-message sentence: the action door answers a sandbox fault (a capability denial) and a crash with `500 INTERNAL_ERROR`, `Internal server error`, and the `/data` classification's unclassified-fault terminal answers the same sentence. That is the one sentence the doors already give for a fault with no business message. ## Readings the dispatch asked for - **(a) The hook door crosses the same writer: holds.** A sandboxed hook body's plain-object throw broke the same way, behind a host handler's write, behind a script body's write (two VM hops, via the nested-refusal relay), and in the `/data` classification. All three are measured and pinned. - **(b) No wrapper or `SandboxError:` prefix reaches the client: holds.** The pump loop builds `innerMessage` as `userFacingMessage(withoutSandboxErrorPrefix(__error))`. The new flattening is `Error: MESSAGE`, which those two strips turn back into exactly `MESSAGE`. The pins assert the client sentence by equality, so a wrapper or prefix would turn them red. - **The `400` question: not this writer.** The status comes from `resolveThrownHttpError` (`@objectstack/types`): `.status`, then `.statusCode`, then the door's fallback, never a status derived from the code. A thrown `Error` carrying `DUPLICATE_RECORD` and no `status` also answers `400 DUPLICATE_RECORD`, and a plain object that declares `status: 409` answers `409`. So it is not specific to plain objects, and nothing in the reject handler decides it. The resolver's precedence table and ADR-0112's 2026-08-27 amendment ("`SandboxError` answers **400**") both declare this behaviour. Not folded in. ## Tests - `packages/runtime/src/sandbox/plain-object-throw-sentence.test.ts` (new, 10, beside the change). Plain object with a message (action and hook origin); no message; `[]`; a message beginning `Error: `; a named plain object. CONTROLS: `Error` (the log reads `action 'submit' threw: Error: x`, exactly as before), `TypeError`, a string, a custom `toString`. - `packages/runtime/src/sandbox-plain-object-throw-client-sentence.test.ts` (new, 10): the door rows above. Action door (2 + CONTROL), hook at the action door (behind a handler: 2; two VM hops: 1; CONTROL), `/data` classification (2 + CONTROL). - Ablation, through `node scripts/ablation-replace.mjs` (wrap mode) with an outer `trap … EXIT INT TERM` restore on the absolute path. The anchor `globalThis.__error = flat !== undefined ? flat : (…)` was replaced by the old expression and a marker: anchor 1 to 0, blob `bfc8615afe02` to `d757d0101457`. Result `Tests 13 failed | 7 passed (20)`. Exactly the 13 plain-object rows went red (6 unit, 7 door: `expected 'undefined: That address is already on…'`, `expected '[object Object]'`, `expected 'TypeError: forged'`, and the `[]` row timing out). All 7 CONTROLS stayed green. Restore: blob == HEAD `bfc8615afe02`, and `git diff HEAD` is empty. The pins import the runner from `src` by relative path, so no build sits in the resolution path. At `a4145db7b2`: - `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 files / 190 errors / 68 pinned signatures held. Both new test files are in `tsconfig.test.json`'s program (`--listFilesOnly`: 2). - The two new files by path: `Test Files 2 passed (2)`, `Tests 20 passed (20)`. ## Gates At `a4145db7b2`, `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 61 commands from 4 changed paths (merge base `9e0945cf4`). All 61 ran, with exit codes captured before any pipe. 60 answered exit 0 on the first pass. `check:dual-build-cjs-loads` answered `PREREQUISITE NOT MET` (exit 3, no workspace `dist`). After `pnpm build` (75 tasks), it answered exit 0 (110 entry points across 68 packages). `--ran`: `61 derived, 61 run, 0 NOT-MEASURED, 0 UNRUN`, exit 0. Lint: a narrowed run, in the scoped form `eslint.config.mjs`'s own header prescribes. - `node --stack-size=4000 node_modules/eslint/bin/eslint.js --no-inline-config packages/runtime` (the `pnpm lint` binary and flags): exit 0. - `--format json` over the four changed files: 0 errors, 0 warnings. The changeset is ignored by the config, as under `pnpm lint`. - Positive control: the same command reds on a restricted root `@objectstack/spec` import (`Data`) piped on stdin at a `packages/runtime` path: exit 1, `no-restricted-imports`. CI's families are this PR's check-runs. ## Acceptance notes - In-process: a caller that catches the `SandboxError` for such a throw reads the value's `message` as `innerMessage`. Its log-only `message` now reads `KIND 'NAME' threw: Error: MESSAGE`. The changeset states it. - A plain object's `message` that begins with a native error name (`TypeError: …`) is now read as a crash (`500`), the same way `new Error('TypeError: …')` already is. Before, the `undefined: ` prefix hid it. - Not addressed here, and reported to the dispatching seat: a rejection whose flattening throws, or flattens to an empty string, is never recorded. `throw ''`, `throw Object.create(null)` and a throwing `message` getter all leave the pump to spin to the wall-clock ceiling, then answer `500` with a "while awaiting host calls" log line. Measured before and after; this PR keeps that path exactly as today, as the dispatch ruled. - `origin/main` moved by one commit after this branch was cut (`a515abdd8c`, PR #23719, a `packages/rest` refactor with no file in common with this diff). It is not merged here; the PR's CI runs on the merge ref. --- _Generated by [Claude Code](https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f40d877 commit 6ee483c

4 files changed

Lines changed: 418 additions & 2 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
fix(runtime): a sandboxed body that throws a plain `{ code, message }` object is answered with its own `message` as the client sentence, never `undefined: MESSAGE` or `[object Object]`; its code is kept (#23718)
6+
7+
**Client sentence change, status and code unchanged.** It applies to `POST /api/v1/actions/:object/:action` on a `type: 'script'` action whose sandboxed body throws a value that is not an `Error`, and to a sandboxed hook body that does the same (its refusal at the action door behind a write, and in the `/data` error classification). The QuickJS runner flattened every thrown value as `NAME: MESSAGE`, else `String(value)`. A plain object has no class name and no `toString` of its own, so the doors served `undefined: MESSAGE`, or `[object Object]` when it had no `message`.
8+
9+
- `throw { code: 'EMAIL_TAKEN', message: 'That address is already on file' }`
10+
- FROM `{"success":false,"error":{"code":"VALIDATION_ERROR","message":"undefined: That address is already on file","httpStatus":400,"declaredCode":"EMAIL_TAKEN"}}`
11+
- TO `{"success":false,"error":{"code":"VALIDATION_ERROR","message":"That address is already on file","httpStatus":400,"declaredCode":"EMAIL_TAKEN"}}`
12+
- `throw { code: 'DUPLICATE_RECORD' }`
13+
- FROM `{"success":false,"error":{"code":"DUPLICATE_RECORD","message":"[object Object]","httpStatus":400}}`
14+
- TO `{"success":false,"error":{"code":"DUPLICATE_RECORD","message":"Internal server error","httpStatus":400}}`
15+
16+
A value with no usable `message` and no `toString` of its own is answered with `Internal server error`, the sentence every door already answers for a fault with no business message. To give the caller words, throw a `message` (or an `Error`).
17+
18+
Two outcomes move with it, both for a thrown plain object only:
19+
20+
- A `name` on a plain object is a field, not a class: `throw { name: 'TypeError', message: 'forged' }` was read as a crash (`500 INTERNAL_ERROR`); it is now a refusal, `400` with `forged`.
21+
- A plain object whose `String()` is empty (`throw []`) recorded no rejection, so the request waited for the sandbox's wall-clock ceiling and answered `500`; it now answers at once with the no-message sentence.
22+
23+
Control, unchanged: a thrown `Error` (or subclass) keeps `NAME: MESSAGE` in the server log and the client sentence it got before. A thrown string is its own sentence, and an object with a `toString` of its own keeps it.
24+
25+
In-process: the `SandboxError` a caller catches for such a throw carries the value's `message` as `innerMessage`, and its log-only `message` reads `KIND 'NAME' threw: Error: MESSAGE`.
26+
27+
Clause-②: no
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#23718] A sandboxed body that throws a plain `{ code, message }` object is
5+
* answered at the doors with that `message` as the client sentence, and its
6+
* code kept, with no change at any door.
7+
*
8+
* ## What was wrong
9+
*
10+
* The wrapper's reject handler (`sandbox/quickjs-runner.ts`) flattened every
11+
* rejection as `<name>: <message>`, else `String(e)`, and that string became
12+
* the `innerMessage` every door serves as the caller's sentence. A plain object
13+
* has no class name and no `toString` of its own, so
14+
* `POST /api/v1/actions/:object/:action` answered
15+
* `400 … "undefined: That address is already on file"` for
16+
* `throw { code: 'EMAIL_TAKEN', message: 'That address is already on file' }`
17+
* and `400 DUPLICATE_RECORD "[object Object]"` for
18+
* `throw { code: 'DUPLICATE_RECORD' }`. A sandboxed HOOK body throwing the same
19+
* values broke the same way, at the action door behind a write and in the
20+
* `/data` classification.
21+
*
22+
* ## The rig
23+
*
24+
* PR #23717's: better-sqlite3 `:memory:` under the real `ObjectQL`, action
25+
* bodies bound through `actionBodyRunnerFactory` over a real
26+
* `QuickJSScriptRunner`, hook bodies through `hookBodyRunnerFactory`, and the
27+
* dispatcher reached through `dispatch()` over a real Fetch `Request`, the
28+
* identity step the one stubbed seam. The `/data` classification is read at
29+
* function level (`mapDataError`), as PR #23717 read it.
30+
*
31+
* The sentence IS the contract here (the author's own words, relayed verbatim),
32+
* so it is asserted exactly; for a value with no message it is asserted to be
33+
* the shared `INTERNAL_ERROR_MESSAGE` constant, never a literal.
34+
*/
35+
36+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
37+
import { ObjectQL, bindHooksToEngine } from '@objectstack/objectql';
38+
import { SqlDriver } from '@objectstack/driver-sql';
39+
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
40+
import { INTERNAL_ERROR_MESSAGE, mapDataError } from '@objectstack/types';
41+
import { HttpDispatcher } from './http-dispatcher.js';
42+
import { actionBodyRunnerFactory, hookBodyRunnerFactory } from './sandbox/body-runner.js';
43+
import { QuickJSScriptRunner } from './sandbox/quickjs-runner.js';
44+
45+
const PREFIX = '/api/v1';
46+
/** `registry.registerObject` requires a package id (see the sibling real-stack tests). */
47+
const TEST_PACKAGE_ID = 'objectstack-test';
48+
const SENTENCE = 'That address is already on file';
49+
50+
/** A `type: 'script'` action whose body is `source`. */
51+
const scriptAction = (name: string, source: string) => ({
52+
name,
53+
label: name,
54+
type: 'script',
55+
body: { language: 'js', source, capabilities: ['api.read', 'api.write'] },
56+
});
57+
58+
/** A write to `tdoc` whose `name` tells the sandboxed `guard` hook what to throw. */
59+
const insertTdoc = (name: string) =>
60+
`await ctx.api.object('tdoc').insert({ id: 't_' + Math.random().toString(36).slice(2), name: '${name}' });`;
61+
62+
const ACTIONS = [
63+
scriptAction('throw_message', `throw { code: 'EMAIL_TAKEN', message: '${SENTENCE}' };`),
64+
scriptAction('throw_no_message', `throw { code: 'DUPLICATE_RECORD' };`),
65+
// CONTROL — a thrown `Error` answers as before.
66+
scriptAction('throw_error', `throw new Error('x');`),
67+
// Two VM hops: this body's write fires `guard`'s sandboxed hook, which throws the plain object.
68+
scriptAction('write_hooked_message', `${insertTdoc('message')} return { written: true };`),
69+
];
70+
71+
/** Host-code handlers whose engine write fires `guard`'s sandboxed hook. */
72+
const HOOKED_BY_HANDLER = ['message', 'no_message', 'error'] as const;
73+
74+
const PDOC = {
75+
name: 'pdoc',
76+
label: 'Plain-throw doc',
77+
fields: {
78+
id: { name: 'id', type: 'text' as const, primaryKey: true, label: 'ID' },
79+
name: { name: 'name', type: 'text' as const, label: 'Name' },
80+
},
81+
actions: [
82+
...ACTIONS,
83+
...HOOKED_BY_HANDLER.map((k) => ({ name: `hooked_${k}`, label: `Hooked ${k}`, type: 'script', target: `hooked_${k}` })),
84+
],
85+
};
86+
87+
const TDOC = {
88+
name: 'tdoc',
89+
label: 'Hook-guarded doc',
90+
fields: {
91+
id: { name: 'id', type: 'text' as const, primaryKey: true, label: 'ID' },
92+
name: { name: 'name', type: 'text' as const, label: 'Name' },
93+
},
94+
};
95+
96+
/** A sandboxed `beforeInsert` hook body that throws what the record's `name` asks for. */
97+
const GUARD_HOOK = {
98+
name: 'guard',
99+
object: 'tdoc',
100+
events: ['beforeInsert'],
101+
body: {
102+
language: 'js',
103+
source: `
104+
if (ctx.input.name === 'message') throw { code: 'EMAIL_TAKEN', message: '${SENTENCE}' };
105+
if (ctx.input.name === 'no_message') throw { code: 'DUPLICATE_RECORD' };
106+
if (ctx.input.name === 'error') throw new Error('x');
107+
`,
108+
capabilities: [],
109+
},
110+
};
111+
112+
const MEMBER = { userId: 'u_member', isSystem: false };
113+
114+
let engine: ObjectQL;
115+
let dispatcher: HttpDispatcher;
116+
117+
beforeAll(async () => {
118+
engine = new ObjectQL();
119+
engine.registerDriver(new SqlDriver({
120+
client: 'better-sqlite3',
121+
connection: { filename: ':memory:' },
122+
useNullAsDefault: true,
123+
}), true);
124+
await engine.init();
125+
for (const o of [PDOC, TDOC]) engine.registry.registerObject(o as any, TEST_PACKAGE_ID);
126+
await engine.syncSchemas();
127+
128+
const ql = engine as any;
129+
const bindBody = actionBodyRunnerFactory(new QuickJSScriptRunner(), { ql: engine, appId: 'pdoc_app' });
130+
for (const action of ACTIONS) ql.registerAction('pdoc', action.name, bindBody({ ...action, object: 'pdoc' }));
131+
for (const k of HOOKED_BY_HANDLER) {
132+
ql.registerAction('pdoc', `hooked_${k}`, async (ctx: any) => {
133+
await ctx.engine.insert('tdoc', { id: 't_' + Math.random().toString(36).slice(2), name: k });
134+
return { written: true };
135+
});
136+
}
137+
engine.setDefaultBodyRunner(hookBodyRunnerFactory(new QuickJSScriptRunner(), { ql: engine, appId: 'pdoc_app' }));
138+
bindHooksToEngine(engine, [GUARD_HOOK as any], { packageId: TEST_PACKAGE_ID });
139+
140+
const protocol = new ObjectStackProtocolImplementation(engine as any);
141+
const services: Record<string, unknown> = { objectql: engine, data: engine, protocol };
142+
const get = (n: string) => services[n] ?? null;
143+
const kernel: any = { context: { getService: get }, getService: get, getServiceAsync: async (n: string) => get(n) };
144+
dispatcher = new HttpDispatcher(kernel);
145+
(dispatcher as any).timedResolveExecutionContext = async () => ({ ...MEMBER });
146+
}, 60_000);
147+
148+
afterAll(async () => {
149+
try { await engine?.destroy(); } catch { /* noop */ }
150+
});
151+
152+
/** `POST /api/v1/actions/pdoc/<action>` as `@objectstack/hono`'s catch-all hands it to `dispatch()`. */
153+
async function postAction(action: string) {
154+
const path = `/actions/pdoc/${action}`;
155+
const request = new Request(`http://localhost${PREFIX}${path}`, {
156+
method: 'POST',
157+
headers: { 'content-type': 'application/json' },
158+
body: '{}',
159+
});
160+
const res: any = await dispatcher.dispatch('POST', path, {}, {}, { request } as any, PREFIX);
161+
return res.response as { status: number; body: any };
162+
}
163+
164+
/** `{ code: 'EMAIL_TAKEN', message }`: the message verbatim, the unregistered code on `declaredCode` as before. */
165+
function expectMessageSentence(response: { status: number; body: any }) {
166+
expect(response.status, JSON.stringify(response.body)).toBe(400);
167+
const error = response.body?.error;
168+
expect(error?.message).toBe(SENTENCE);
169+
expect(error?.code).toBe('VALIDATION_ERROR');
170+
expect(error?.declaredCode).toBe('EMAIL_TAKEN');
171+
}
172+
173+
/** `{ code: 'DUPLICATE_RECORD' }`: the no-business-message sentence, the registered code kept. */
174+
function expectNoMessageSentence(response: { status: number; body: any }) {
175+
expect(response.status, JSON.stringify(response.body)).toBe(400);
176+
const error = response.body?.error;
177+
expect(error?.message).toBe(INTERNAL_ERROR_MESSAGE);
178+
expect(error?.code).toBe('DUPLICATE_RECORD');
179+
expect(JSON.stringify(response.body)).not.toContain('[object Object]');
180+
}
181+
182+
/** The error `engine.insert('tdoc')` rejects with when `guard` throws for `name`. */
183+
async function hookRefusal(name: string): Promise<unknown> {
184+
const err = await engine.insert('tdoc', { id: 'd_' + Math.random().toString(36).slice(2), name }).then(
185+
() => undefined,
186+
(e: unknown) => e,
187+
);
188+
expect(err, `guard did not refuse '${name}'`).toBeDefined();
189+
return err;
190+
}
191+
192+
describe('[#23718] the action door answers a body\'s thrown plain object with its own message', () => {
193+
it('{ code, message }: the message is the client sentence, and the code is kept', async () => {
194+
expectMessageSentence(await postAction('throw_message'));
195+
});
196+
197+
it('{ code } with no message: not [object Object], and the code is kept', async () => {
198+
expectNoMessageSentence(await postAction('throw_no_message'));
199+
});
200+
201+
it('CONTROL — a thrown Error answers as before', async () => {
202+
const response = await postAction('throw_error');
203+
expect(response.status, JSON.stringify(response.body)).toBe(400);
204+
expect(response.body?.error?.code).toBe('VALIDATION_ERROR');
205+
expect(response.body?.error?.message).toBe('x');
206+
});
207+
});
208+
209+
describe('[#23718] a sandboxed hook body\'s thrown plain object, at the action door behind a write', () => {
210+
it('{ code, message }, behind a host handler\'s write', async () => {
211+
expectMessageSentence(await postAction('hooked_message'));
212+
expect(await engine.find('tdoc', {})).toHaveLength(0);
213+
});
214+
215+
it('{ code } with no message, behind a host handler\'s write', async () => {
216+
expectNoMessageSentence(await postAction('hooked_no_message'));
217+
});
218+
219+
it('{ code, message }, behind a script body\'s write (two VM hops)', async () => {
220+
expectMessageSentence(await postAction('write_hooked_message'));
221+
expect(await engine.find('tdoc', {})).toHaveLength(0);
222+
});
223+
224+
it('CONTROL — the hook\'s thrown Error answers as before', async () => {
225+
const response = await postAction('hooked_error');
226+
expect(response.status, JSON.stringify(response.body)).toBe(400);
227+
expect(response.body?.error?.message).toBe('x');
228+
});
229+
});
230+
231+
describe('[#23718] the /data classification of the same hook refusals', () => {
232+
it('{ code, message }: the message is the sentence, the code on declaredCode', async () => {
233+
const { status, body } = mapDataError(await hookRefusal('message'), 'tdoc');
234+
expect(status, JSON.stringify(body)).toBe(400);
235+
expect(body.error).toBe(SENTENCE);
236+
expect(body.declaredCode).toBe('EMAIL_TAKEN');
237+
});
238+
239+
it('{ code } with no message: not [object Object], and the code is kept', async () => {
240+
const { status, body } = mapDataError(await hookRefusal('no_message'), 'tdoc');
241+
expect(status, JSON.stringify(body)).toBe(400);
242+
expect(body.error).toBe(INTERNAL_ERROR_MESSAGE);
243+
expect(body.code).toBe('DUPLICATE_RECORD');
244+
});
245+
246+
it('CONTROL — the hook\'s thrown Error is classified as before', async () => {
247+
const { status, body } = mapDataError(await hookRefusal('error'), 'tdoc');
248+
expect(status, JSON.stringify(body)).toBe(400);
249+
expect(body.error).toBe('x');
250+
});
251+
});
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#23718] A sandboxed body that throws a VALUE which is not an `Error` — the
5+
* plain `{ code, message }` object metadata app bodies throw — crosses out of
6+
* the VM with its own `message` as `innerMessage`, the field every door serves
7+
* as the caller's sentence.
8+
*
9+
* ## What was wrong
10+
*
11+
* The wrapper's reject handler flattened every rejection as
12+
* `<name>: <message>`, else `String(e)`. A plain object has no class name and
13+
* no `toString` of its own, so a body throwing
14+
* `{ code: 'EMAIL_TAKEN', message: 'That address is already on file' }` was
15+
* answered `undefined: That address is already on file`, and one throwing
16+
* `{ code: 'DUPLICATE_RECORD' }` was answered `[object Object]`.
17+
*
18+
* ## What holds now
19+
*
20+
* A non-`Error` object is flattened under the default name `Error`, so its
21+
* `message` is the sentence byte for byte. With no usable `message` and no
22+
* `toString` of its own, the sentence is `INTERNAL_ERROR_MESSAGE`, the one
23+
* every door answers for a fault with no business message. `code` rides
24+
* `__errorInfo` as before. A thrown `Error` (and subclass), a thrown primitive,
25+
* and a value with a `toString` of its own are flattened exactly as before.
26+
*
27+
* The door rows live in `../sandbox-plain-object-throw-client-sentence.test.ts`.
28+
*/
29+
30+
import { describe, it, expect } from 'vitest';
31+
import { INTERNAL_ERROR_MESSAGE } from '@objectstack/types';
32+
33+
import { QuickJSScriptRunner, SandboxError } from './quickjs-runner.js';
34+
import type { ScriptContext, ScriptRunOptions } from './script-runner.js';
35+
36+
const runner = new QuickJSScriptRunner({ hookTimeoutMs: 10_000 });
37+
const actionOpts: ScriptRunOptions = { origin: { kind: 'action', name: 'submit' } };
38+
const hookOpts: ScriptRunOptions = { origin: { kind: 'hook', name: 'guard' } };
39+
40+
const SENTENCE = 'That address is already on file';
41+
42+
/** Run `source` as a body and hand back the error it rejected with. */
43+
async function thrownBy(source: string, opts: ScriptRunOptions = actionOpts): Promise<SandboxError> {
44+
const err = await runner
45+
.run({ language: 'js', source, capabilities: [] }, { input: {} } as ScriptContext, opts)
46+
.then(
47+
() => undefined,
48+
(e: unknown) => e,
49+
);
50+
expect(err).toBeInstanceOf(SandboxError);
51+
return err as SandboxError;
52+
}
53+
54+
describe('[#23718] a thrown plain object crosses with its own message as the sentence', () => {
55+
it('{ code, message }: the message is innerMessage, and the code is kept', async () => {
56+
const err = await thrownBy(`throw { code: 'EMAIL_TAKEN', message: '${SENTENCE}' };`);
57+
expect(err.innerMessage).toBe(SENTENCE);
58+
expect(err.code).toBe('EMAIL_TAKEN');
59+
expect(err.message).not.toContain('undefined:');
60+
});
61+
62+
it('…on the hook origin too (both wrappers share the handler)', async () => {
63+
const err = await thrownBy(`throw { code: 'EMAIL_TAKEN', message: '${SENTENCE}' };`, hookOpts);
64+
expect(err.innerMessage).toBe(SENTENCE);
65+
expect(err.code).toBe('EMAIL_TAKEN');
66+
});
67+
68+
it('{ code } with no message: the no-business-message sentence, never [object Object]', async () => {
69+
const err = await thrownBy(`throw { code: 'DUPLICATE_RECORD' };`);
70+
expect(err.innerMessage).toBe(INTERNAL_ERROR_MESSAGE);
71+
expect(err.code).toBe('DUPLICATE_RECORD');
72+
expect(err.message).not.toContain('[object Object]');
73+
});
74+
75+
it('a value whose String() is empty gets the same sentence (it used to record no rejection)', async () => {
76+
const err = await thrownBy(`throw [];`);
77+
expect(err.innerMessage).toBe(INTERNAL_ERROR_MESSAGE);
78+
});
79+
80+
it('a message that itself begins "Error: " crosses byte for byte', async () => {
81+
const err = await thrownBy(`throw { message: 'Error: lead is incomplete' };`);
82+
expect(err.innerMessage).toBe('Error: lead is incomplete');
83+
});
84+
85+
it('a name on a plain object is a field, never a frame: no prefix, and no crash reading', async () => {
86+
const err = await thrownBy(`throw { name: 'TypeError', message: 'forged' };`);
87+
expect(err.innerMessage).toBe('forged');
88+
});
89+
});
90+
91+
describe('[#23718] CONTROL — everything else is flattened exactly as before', () => {
92+
it('a thrown Error still reads `name: message` in the log, and its message is the sentence', async () => {
93+
const err = await thrownBy(`throw new Error('x');`);
94+
expect(err.message).toBe("action 'submit' threw: Error: x");
95+
expect(err.innerMessage).toBe('x');
96+
});
97+
98+
it('a thrown Error subclass keeps its name in both', async () => {
99+
const err = await thrownBy(`throw new TypeError('t');`);
100+
expect(err.message).toBe("action 'submit' threw: TypeError: t");
101+
expect(err.innerMessage).toBe('TypeError: t');
102+
});
103+
104+
it('a thrown string is its own sentence', async () => {
105+
const err = await thrownBy(`throw 'Name is required';`);
106+
expect(err.innerMessage).toBe('Name is required');
107+
});
108+
109+
it('an object with a toString of its own keeps it', async () => {
110+
const err = await thrownBy(`throw { toString() { return 'custom words'; } };`);
111+
expect(err.innerMessage).toBe('custom words');
112+
});
113+
});

0 commit comments

Comments
 (0)