From d457a70fbdf05b9fb49571f0f5d825abbba5664c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 18:26:51 +0000 Subject: [PATCH 1/2] fix(types): the data door answers the sandbox's own fault as the fault it is, before the unknown-object heuristic Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude --- .../types/src/data-error-classification.ts | 33 ++++++++++++++++--- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/packages/types/src/data-error-classification.ts b/packages/types/src/data-error-classification.ts index 97f36d1190a..2456ae803e5 100644 --- a/packages/types/src/data-error-classification.ts +++ b/packages/types/src/data-error-classification.ts @@ -795,11 +795,12 @@ function isSandboxOrigin(error: any): boolean { * runner's row report (`@objectstack/core`) sits below `@objectstack/runtime` * and never sees the class. * - * ⛔ Not a withholding rule. {@link mapDataError} asks nothing here: it answers - * these errors from their message like any other unclassified throw, which for - * the runner's fault texts is its sanitised `500 INTERNAL_ERROR` terminal. This - * predicate lets the import row recognise the error so it can take that door's - * sentence, as it does for a crash; the verdict stays the door's. + * [#22868] {@link classifyDataError} asks it at its top, beside + * {@link isSandboxCrash}, and answers the sanitised `500 INTERNAL_ERROR` + * terminal — the answer it already gave these errors from their message, + * except where a text named the request's own object and the unknown-object + * heuristic answered `404`. The import row still takes that door's sentence + * through this predicate, as it does for a crash; the verdict stays the door's. */ export function isSandboxOwnFault(error: unknown): boolean { const name = (error as { name?: unknown } | null | undefined)?.name; @@ -1336,6 +1337,28 @@ function classifyDataError(error: any, object?: string): { status: number; body: // ⛔ The terminal is not duplicated — it MOVED here from inside the unwrap // door below, which is why that door now reads a body that REPORTED. if (isSandboxCrash(error)) return UNCLASSIFIED_FAULT(); + // [#22868] …and so is the sandbox's OWN fault ({@link isSandboxOwnFault}): + // a capability denial, an unavailable `ctx.api`, a missing `ctx.api` + // method, a budget, an install or marshal failure. Its producer's contract + // calls it a fault, and it carries no `code`, no `status` and no + // `innerMessage`, so nothing below could ever read it as anything else but + // its message TEXT. Two of those texts name the object the body addressed + // — `capability 'api.read' not granted to hook 'H' (called + // ctx.api.object('OBJ').find)` and `ctx.api.object('OBJ').find not + // implemented` — and when OBJ was the request's own object the + // unknown-object substring limb (a quoted object name plus `not`) answered + // `404 OBJECT_NOT_FOUND`, telling the caller the object does not exist. + // + // Asked HERE rather than beside that limb, because every message limb + // below reads text the runner interpolates from the body's own arguments + // (`ctx.api.object(name)`, `ctx.title(field)`), so a limb-local exclusion + // would fix one limb and leave the others free to misread the same fault. + // The answer is the terminal the other runner faults already reached, so + // only the misread ones move; the nested crash the pump loop relays as a + // fault reached it too, through the wrapper fallback below. ⛔ Not a second + // rule: the predicate reads the producer's identity, never the text, so + // the same words thrown by anything else keep the heuristic's answer. + if (isSandboxOwnFault(error)) return UNCLASSIFIED_FAULT(); // [commit 6d178a408] The bespoke structured arms first, exactly as they were inline // here — same arms, same order, same position — now stated once so // {@link resolveErrorResponse} can ask them before ITS passthrough too. From 31b25500d1d89d8548c5535296db713485247399 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 18:29:10 +0000 Subject: [PATCH 2/2] test(core): the runner fault enumeration names the requested object, and holds its controls Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude --- .../22868-sandbox-fault-not-unknown-object.md | 13 +++++ .../import-runner-sandbox-fault-row.test.ts | 52 +++++++++++++++---- 2 files changed, 56 insertions(+), 9 deletions(-) create mode 100644 .changeset/22868-sandbox-fault-not-unknown-object.md diff --git a/.changeset/22868-sandbox-fault-not-unknown-object.md b/.changeset/22868-sandbox-fault-not-unknown-object.md new file mode 100644 index 00000000000..97c37072a6e --- /dev/null +++ b/.changeset/22868-sandbox-fault-not-unknown-object.md @@ -0,0 +1,13 @@ +--- +'@objectstack/types': patch +--- + +fix(types): the data door answers the sandbox's own fault as a fault, not as "this object does not exist" (#22868) + +Clause-②: no + +**What was wrong.** The QuickJS runner raises some `SandboxError`s itself. These carry no `innerMessage`. Two of them name the object the hook body addressed: a denied capability on `ctx.api.object(...)` (`capability 'api.read' not granted to hook 'guard' (called ctx.api.object('account').find)`) and a `ctx.api` method the host does not implement (`ctx.api.object('account').find not implemented`). When that object was the one the request named, `mapDataError` read the quoted object name plus the word `not` as an unknown object. `POST /api/v1/data/account` then answered `404 OBJECT_NOT_FOUND` with `Object 'account' is not registered`. Every other runner fault already answered `500 INTERNAL_ERROR`, which is what the runner's contract says these errors are. + +**What changes.** `mapDataError` checks `isSandboxOwnFault` first, next to the existing check for a sandboxed body that crashed. An error the sandbox raised itself now answers `500 INTERNAL_ERROR` with `Internal server error`, whatever its text names. The runner's text goes to the server log only. The sync `/import` row and the `/import/jobs` stored row read that same sentence. + +**Unchanged.** A driver's unknown-object text (`no such table: account`, `relation "account" does not exist`) still answers `404 OBJECT_NOT_FOUND`. So does the same wording thrown by anything other than the sandbox: the check reads the error's producer, not its words. A hook body's refusal keeps its sentence and status, and a crash keeps its `500`. diff --git a/packages/core/src/utils/import-runner-sandbox-fault-row.test.ts b/packages/core/src/utils/import-runner-sandbox-fault-row.test.ts index 1ba1573f846..540583eceb2 100644 --- a/packages/core/src/utils/import-runner-sandbox-fault-row.test.ts +++ b/packages/core/src/utils/import-runner-sandbox-fault-row.test.ts @@ -48,11 +48,13 @@ * not raise. `import-runner-sandbox-refusal-row.test.ts` §4 is the driver-text * control #22718's ablation B pinned, and it is not edited here. * - * The sample object name in a rendered message is deliberately NOT the - * import's object. `mapDataError`'s unknown-object heuristic answers `404 - * OBJECT_NOT_FOUND` for a text that names the requested object and contains - * `not`, which a capability denial on that object does. That is a door - * defect outside this card; the row does not copy a 4xx sentence. + * [#22868] The sample object name in a rendered message IS the import's + * object. Two sites name it — the `ctx.api.object(…)` capability gate and the + * missing-method arm — and `mapDataError`'s unknown-object heuristic answered + * those `404 OBJECT_NOT_FOUND` (a quoted requested-object name plus `not`) + * until the door asked `isSandboxOwnFault` first. So §1 holds each site that + * names the requested object to the door's `500`, and §3 holds the controls + * that keep the heuristic's other answers where they were. */ import { readFileSync } from 'node:fs'; @@ -68,10 +70,12 @@ const HERE = dirname(fileURLToPath(import.meta.url)); const RUNNER = resolve(HERE, '../../../runtime/src/sandbox/quickjs-runner.ts'); const SOURCE = ts.createSourceFile(RUNNER, readFileSync(RUNNER, 'utf8'), ts.ScriptTarget.Latest, true); +const OBJECT = 'mz_locked'; + /** * One sample per interpolation the runner's fault messages use. A message * naming an expression not listed here is unrenderable, and §1's census says - * which. `objectName` is not the import's object (see the header). + * which. `objectName` is the import's object (see the header). */ const SAMPLES: Readonly> = { 'args.origin.kind': 'hook', @@ -82,7 +86,7 @@ const SAMPLES: Readonly> = { wallCeiling: '30000', pumps: '3', 'formatErr(msg)': 'SyntaxError: unexpected token', - objectName: 'mz_other', + objectName: OBJECT, method: 'find', required: 'api.read', fieldName: 'owner', @@ -219,6 +223,11 @@ const census = (() => { const PRODUCER_NAME = producerName(); +/** [#22868] The fault sites whose rendered message quotes the requested object. */ +function namingTheObject(): Site[] { + return census.faults.filter((s) => s.message.includes(`'${OBJECT}'`)); +} + /** The producer's own fault: its declared `name`, and no `innerMessage`. */ function ownFault(message: string): Error { const err = new Error(message); @@ -228,8 +237,6 @@ function ownFault(message: string): Error { type CreateArgs = Parameters[0]; -const OBJECT = 'mz_locked'; - const baseOpts = { objectName: OBJECT, metaMap: new Map([['name', { name: 'name', type: 'text' }]]), @@ -280,6 +287,11 @@ describe('[#22741] §1 — every fault the runner raises itself reads the door\' } // §2's population: the constructions that set `innerMessage` (crash and refusal sites). expect(census.crashes.length).toBeGreaterThanOrEqual(4); + // [#22868] The sites that name the requested object: the `ctx.api.object(…)` + // capability gate and the missing-method arm, each held to `500` below. + const named = namingTheObject(); + expect(named.filter((s) => /^capability '/.test(s.message)).length, 'capability denial').toBeGreaterThanOrEqual(1); + expect(named.filter((s) => /not implemented$/.test(s.message)).length, 'missing ctx.api method').toBeGreaterThanOrEqual(1); }); it.each(census.faults.map((s) => [s.label, s.message] as const))('%s', async (_label, message) => { @@ -331,6 +343,28 @@ describe('[#22741] §3 — controls: what the predicate must NOT change', () => expect((await rowFor(err)).error).toBe(sanitizeRowError(budget())); }); + it.each([ + ['SQLite', `SQLITE_ERROR: no such table: ${OBJECT}`], + ['Postgres', `relation "${OBJECT}" does not exist`], + ])('CONTROL [#22868] — a real %s unknown-object text on the requested object still answers 404', (_driver, text) => { + const door = mapDataError(new Error(text), OBJECT); + expect(door.status).toBe(404); + expect(door.body.code).toBe('OBJECT_NOT_FOUND'); + }); + + it('CONTROL [#22868] — the same object-naming texts, thrown by anything but the sandbox, keep the heuristic\'s 404', () => { + // The door asks the producer's identity, never the words: no second rule. + const named = namingTheObject(); + expect(named.length).toBeGreaterThanOrEqual(2); + for (const { label, message } of named) { + const err = new Error(message); + expect(isSandboxOwnFault(err), label).toBe(false); + const door = mapDataError(err, OBJECT); + expect(door.status, label).toBe(404); + expect(door.body.code, label).toBe('OBJECT_NOT_FOUND'); + } + }); + it('CONTROL — a code-defined hook\'s native TypeError keeps its text, as the filing says', async () => { const text = "Cannot read properties of undefined (reading 'owner')"; const err = () => new TypeError(text);