Skip to content

Commit 3fdb92d

Browse files
fix(rest): hand-built write routes withhold the sandbox's own fault as the data door does (#22885) (#22896)
Fixes #22885 Clause-②: no ## What this does The QuickJS runner raises some `SandboxError`s itself, with no `innerMessage`: a denied capability, a missing or unavailable `ctx.api` method, a CPU budget or wall-clock ceiling, an install or marshal failure, a nested body's fault relayed one VM hop up. `isSandboxOwnFault` (`@objectstack/types`) recognises them off the producer, and the `/data` door answers them `500 INTERNAL_ERROR` / `Internal server error`. Twenty-three hand-built REST write rows relayed the runner's diagnostic text instead. - `packages/rest/src/error-response.ts`: `sandboxFaultAnswer` answers `isSandboxOwnFault` with `resolveErrorResponse`, the same classification door and the same withheld `500` it gives a crash. One predicate, one terminal, no own-fault branch anywhere else. - `packages/rest/src/rest-server.ts`, record-share arms only: `sharingFaultMessage` asks `sandboxFaultAnswer` for its sentence instead of keeping its own sandbox read. The family keeps its own `500 SHARE_*_FAILED` status and code, as it does for a crash (see "The record-share rows" below). - `packages/rest/src/rest-write-route-hook-refusal-sentence.ledger.test.ts` §6: nine own-fault shapes, one per kind the runner raises, over every row the ledger already drives (`ANSWERS_THE_SENTENCE`, held to `REST_ROUTE_LEDGER` by §1, not hand-listed). The crash and declared-5xx shapes stay as the CONTROL, unchanged. Each own-fault case asserts the ADR-0112 `status` + `code`, the doors' withheld sentence, no trace of the runner's text in the body, and that the whole `SandboxError` reaches `console.error` (the file now declares `OS_REST_LOG: 'info'` inside §6, as `check:rest-log-spy-declared` requires). - `.changeset/22885-sandbox-own-fault-rest-write-routes.md`: `@objectstack/rest` `patch`, `Clause-②: no`. ## Measured at RED (the card's "Not measured"), BASE `2e11d9006c` The new §6 cases, run before the fix: **207 failed / 370 passed**, which is exactly 23 rows x 9 shapes. Every named downstream was called, and no upstream arm (a prefix map, a 403/404 arm, `classifiedRefusalAnswer`) intercepted any shape. Every row reached its own terminal with the runner's text: | Rows | Status and code at RED | |---|---| | `POST /analytics/dataset/query` | `500 ANALYTICS_QUERY_FAILED` | | `POST /security/explain` | `500 EXPLAIN_FAILED` | | `POST` / `DELETE /data/:object/:id/shares` | `500 SHARE_GRANT_FAILED` / `SHARE_REVOKE_FAILED` | | the three sharing-rule writes | `500 RULE_*_FAILED` | | suggested-binding `confirm` / `dismiss` | `500 SUGGESTION_*_FAILED` | | the nine `POST /approvals/requests/:id/*` writes | `500 APPROVAL_*_FAILED` | | `POST /packages/publish` | `500 INTERNAL_ERROR` (text relayed) | | the four `POST /datasources/:name/external/*` routes | `400 EXTERNAL_DATASOURCE_ERROR` / `EXTERNAL_IMPORT_ERROR` | The other 20 driven rows already answered every shape `500 INTERNAL_ERROR` (the import rows per row inside `200` / `201`), and all 86 control readings already withheld. Before any edit, `sendThrownError` (the classification door) answered all eight runner kinds `500 INTERNAL_ERROR` / `Internal server error`, while `sandboxFaultAnswer` and `classifiedRefusalAnswer` both answered `undefined`. So the change is one line at the function every family already asks. After: 21 rows answer `500 INTERNAL_ERROR` / `Internal server error`. The two record-share write rows answer `500 SHARE_*_FAILED` / `Internal server error`. `GET /data/:object/:id/shares`, which the write ledger does not drive, was measured by a throwaway probe (deleted): `500 SHARES_LIST_FAILED` / `Internal server error`; the same words on a plain `Error` keep the relayed text there. ## The record-share rows: one reading of the triage text Triage says the record-share rows "go through the same answer" and "Do not patch their own terminal separately", and also that "The crash rows stay as the CONTROL". The dispatch's reading was that these arms ask `sandboxFaultAnswer` as the other families do. Measured, that literal shape moves the CONTROL: on the two share write rows a crash would go from `500 SHARE_*_FAILED` to `500 INTERNAL_ERROR`, and a sandboxed declared 5xx from `500 SHARE_*_FAILED` to `503 SERVICE_UNAVAILABLE`, four existing assertions, and it would delete the ledger's `OWN_FAULT_TERMINAL` exception that #22764 recorded as "a decision this card does not take". So the share arms' sentence is `sandboxFaultAnswer`'s (one predicate, no own-fault check of their own), and their status and code stay their own, exactly as for a crash. `OWN_FAULT_TERMINAL` keeps its two rows; its reason now names the shared read. If the family's status and code should move too, that is a four-line change plus deleting `OWN_FAULT_TERMINAL`, and it moves the crash and declared-5xx answers on these routes with it. ## Files outside the claim's surface, declared `packages/rest/src/package-routes.ts` and `packages/rest/src/external-datasource-routes.ts`, comment-only. Both docblocks said sentences this change makes false ("`sandboxFaultAnswer` is the doors' answer for exactly those two", "Every other throw keeps this family's own `400`"). No code there changes; both callers already ask `sandboxFaultAnswer` first. ## Reverse verification (one-time; all from committed `24f3dc6f46`, via `scripts/ablation-replace.mjs`, each restore proven by blob == HEAD and an empty `git diff HEAD`) The tested subjects resolve by relative `src` import, not through a dependency's `exports`, so no `dist` rebuild leg applies. - **A**, delete the own-fault line in `sandboxFaultAnswer`: anchor 1 to 0, blob `1c91226dfd37` to `aa8e4dc71f19`. Ledger **207 failed / 370 passed**, the RED population exactly (the share rows go red too, since their sentence reads the same answer). - **B**, delete the share arm's `sandboxFaultAnswer` read: blob `8e11bff9ffa9` to `e31a3df9f2c5`. **18 failed**, only the two record-share write rows (9 shapes each). - **C**, delete the approvals family's fault-arm `logError`: blob `8e11bff9ffa9` to `96834aaee48f`. **81 failed** (9 approval rows x 9 shapes), each on "the runner's error reaches the server log". The log assertion can fail. ## Tests, at `24f3dc6f46` - `pnpm --filter @objectstack/rest exec vitest run --maxWorkers=2 src/rest-write-route-hook-refusal-sentence.ledger.test.ts`: 577 passed (189 before this change). - `pnpm --filter @objectstack/rest exec vitest run --project local --maxWorkers=2`: 273 files, 5721 passed, 326 skipped. This includes `sharing-envelope.test.ts`. - `pnpm --filter @objectstack/rest exec vitest run --project repo --maxWorkers=2`: 5 files, 209 passed, 1 skipped. This includes the three `error-response-*` suites. - `pnpm --filter @objectstack/rest typecheck`: exit 0, `check:test-typecheck: OK`. `--listFiles` puts the ledger in the `tsconfig.test.json` program (1 hit). - Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` with no paths, on this tree. Reconciled with `--ran`: **65 derived, 64 run at exit 0, 1 NOT-MEASURED, 0 unrun**. - **NOT MEASURED: `pnpm check:dual-build-cjs-loads`**, reason: exit 3, `PREREQUISITE NOT MET`. 42 packages this diff does not touch have no `dist/` in this container. Narrower reading: `@objectstack/rest` is built and `require('./dist/index.cjs')` loads, with 34 exports. CI runs the full gate. - Lint, a proven narrowing rather than `pnpm lint`: `eslint --no-inline-config --format json` over the five touched `.ts` files. ① Population, read from eslint's own config: `--print-config` resolves a config for each of the five (6/6/6/6/5 rules), and the changeset `.md` matches none. ② The JSON result holds 5 files, 0 errors, 0 warnings. ③ Invariance: `eslint.config.mjs` sets no `parserOptions.project` and no `projectService` (its own comment: it "never enables type-aware linting"), and this diff touches no config or data file a rule reads, so it cannot move any untouched file's verdict. ## Acceptance notes - The other `sandboxFaultAnswer` callers in `rest-server.ts` (analytics, explain, sharing rules, suggested bindings, approvals) still describe the answer as "(a crash, or a refusal declaring a 5xx)". That is now incomplete but not false, and it was left alone to keep `rest-server.ts` to the record-share arms. Carrier: none. - `resolveErrorResponse` asks `isSandboxCrash` at its top but not `isSandboxOwnFault`. An own fault reaches `classifyDataError`'s own-fault terminal only because it carries no `status` or `code`. That holds for every raise site in `quickjs-runner.ts` today (no own-fault construction passes `SandboxErrorInfo`), so it is dormant. Carrier: none. - The import rows answer an own fault per row with the withheld sentence. This PR adds no log assertion on them, because their contract is a per-row outcome handed to the caller. --- _Generated by [Claude Code](https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5ce6773 commit 3fdb92d

6 files changed

Lines changed: 153 additions & 13 deletions
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/rest': patch
3+
---
4+
5+
A sandboxed hook or action that fails on the sandbox's own fault (a capability it was not granted, a `ctx.api` method that is missing or unavailable, a CPU budget or wall-clock ceiling, an install or marshal failure) is now answered by the hand-built write routes the way `POST /api/v1/data/:object` answers it: a withheld `500` with `Internal server error`. The runner's diagnostic text, which names the hook, the object and the capability, goes to the server log only.
6+
7+
Clause-②: no
8+
9+
Before, 23 write routes relayed that text to the caller: `POST /analytics/dataset/query`, `POST /security/explain`, `POST` and `DELETE /data/:object/:id/shares`, the three sharing-rule writes, the two suggested-binding writes, the nine approval-request writes, `POST /packages/publish` and the four `POST /datasources/:name/external/*` routes. Most of them answered `500` with their own fault code, and the four external routes answered `400`. Twenty-one of them now answer `500 INTERNAL_ERROR`, the answer each already gave a sandboxed body that crashed. The record-share routes (and the share list, `GET /data/:object/:id/shares`) keep their own `500` and `SHARE_*_FAILED` code, as they do for a crash, and withhold the sentence the same way.
10+
11+
A refusal a sandboxed body throws keeps its own sentence and status on every route. No key, default, enum member or export moves.

‎packages/rest/src/error-response.ts‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ import {
6161
fiveXxArmDisplacesDeclared4xx,
6262
isSandboxCrash,
6363
isSandboxOrigin,
64+
isSandboxOwnFault,
6465
mapDataError,
6566
sandboxBusinessMessage,
6667
structuredCodeAnswer,
@@ -698,7 +699,17 @@ export function classifiedRefusalAnswer(
698699
* - a refusal that DECLARED a server-band status. The doors keep the status
699700
* and withhold the prose (#5437) unless the producer declared it a refusal
700701
* (#16146); the families folded it into their own `500` with the wrapper,
701-
* or relayed the sentence at the declared status.
702+
* or relayed the sentence at the declared status;
703+
* - [#22885] the sandbox's OWN fault ({@link isSandboxOwnFault}): a
704+
* `SandboxError` the runner raised itself, with no `innerMessage` — a
705+
* denied capability, an unavailable `ctx.api` or a missing `ctx.api`
706+
* method, a budget, an install or marshal failure, a nested body's fault
707+
* relayed one VM hop up. It is no sandbox ORIGIN, so it fell past the two
708+
* shapes above to each family's own terminal, which relayed the runner's
709+
* diagnostic text (naming the hook, the object, the capability) where the
710+
* `/data` door answers `500 INTERNAL_ERROR` (#22868). Measured: twenty-three
711+
* write rows across these families answered that text, the four external
712+
* ones at `400`.
702713
*
703714
* ## One answer, not a second fault terminal
704715
*
@@ -712,6 +723,11 @@ export function classifiedRefusalAnswer(
712723
* the original error first: the client no longer reads the words, so the
713724
* operator must.
714725
*
726+
* [#22885] The own fault takes that same terminal, recognised by the predicate
727+
* `classifyDataError` asks (#22868): the producer's identity, never its text,
728+
* so the same words thrown by anything else keep the catching route's answer.
729+
* ⛔ Not a second rule: no own-fault branch of its own here or in a family.
730+
*
715731
* ## What it deliberately leaves alone
716732
*
717733
* - A throw that is not a sandbox's. A producer-declared 5xx from a service
@@ -725,6 +741,7 @@ export function classifiedRefusalAnswer(
725741
export function sandboxFaultAnswer(
726742
error: any,
727743
): { status: number; body: Record<string, unknown> } | undefined {
744+
if (isSandboxOwnFault(error)) return resolveErrorResponse(error);
728745
if (!isSandboxOrigin(error)) return undefined;
729746
if (!isSandboxCrash(error)) {
730747
const declared = declaredHttpStatus(error);

‎packages/rest/src/external-datasource-routes.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,8 @@ export function registerExternalDatasourceRoutes(
418418
* longer reads its words. Before, both reached this family's `400`: a crash
419419
* as the caller's mistake, with the QuickJS wrapper around the native error
420420
* text, and a declared `503` as a client error carrying its sentence.
421+
* [#22885] The sandbox's own fault (a denied capability, a budget) is that
422+
* answer's too, and reached this `400` with the runner's text until then.
421423
*
422424
* ⛔ Every other throw keeps this family's own `400` and code, a declared 4xx
423425
* included: a sandboxed refusal declaring one, and the metadata door's

‎packages/rest/src/package-routes.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -211,10 +211,12 @@ async function refusePackageRequest(
211211
* answered `500 INTERNAL_ERROR` with the QuickJS wrapper around the native
212212
* error text (the business read declines a crash, so the raw message stood),
213213
* and a refusal that DECLARED `503` answered at its status with its sentence.
214-
* {@link sandboxFaultAnswer} is the doors' answer for exactly those two, so it
214+
* {@link sandboxFaultAnswer} is the doors' answer for those two, so it
215215
* is asked first and re-dressed whole, the way the nested families re-dress
216216
* it. The original error is logged first, the one place its words now reach.
217-
* Every other throw is answered below exactly as before.
217+
* [#22885] It answers the sandbox's own fault too (a denied capability, a
218+
* budget), which reached the raw message below until then. Every other throw
219+
* is answered below exactly as before.
218220
*/
219221
function sendThrownError(res: any, error: unknown): void {
220222
const fault = sandboxFaultAnswer(error);

‎packages/rest/src/rest-server.ts‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11936,11 +11936,26 @@ export class RestServer {
1193611936
*
1193711937
* The full wrapper still reaches the operator: every caller logs the
1193811938
* whole error object immediately above its `respondError`.
11939+
*
11940+
* [#22885] …and the sandbox read is the doors' own now. The runner's
11941+
* OWN fault (a denied capability, a missing `ctx.api` method, a
11942+
* budget) carries no `innerMessage`, so the read above let it through
11943+
* and these arms relayed the runner's diagnostic text. The sentence is
11944+
* {@link sandboxFaultAnswer}'s, the answer the other hand-built
11945+
* families give every sandbox fault, so a crash, a declared 5xx and an
11946+
* own fault read one withheld sentence here as there. ⛔ Only the
11947+
* SENTENCE: this family's own `500` and `SHARE_*_FAILED` code stay the
11948+
* recorded exception the write-route ledger names (#22764's
11949+
* `OWN_FAULT_TERMINAL`), a decision this does not take. The
11950+
* `innerMessage` read stays for any sandbox origin that answer declines.
1193911951
*/
11940-
const sharingFaultMessage = (error: any): string =>
11941-
typeof error?.innerMessage === 'string' && error.innerMessage
11952+
const sharingFaultMessage = (error: any): string => {
11953+
const fault = sandboxFaultAnswer(error);
11954+
if (fault !== undefined) return nestedRefusalEnvelope(fault).message;
11955+
return typeof error?.innerMessage === 'string' && error.innerMessage
1194211956
? INTERNAL_ERROR_MESSAGE
1194311957
: String(error?.message ?? error).slice(0, 500);
11958+
};
1194411959

1194511960
// GET — list shares on a record. [ADR-0111 D5] Management-gated in the
1194611961
// service: invisible record → 404, visible-but-not-manager → 403.

‎packages/rest/src/rest-write-route-hook-refusal-sentence.ledger.test.ts‎

Lines changed: 101 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,20 @@
4848
* holds each to the doors' answer: their status and code, their withheld
4949
* sentence, and no trace of the native text or the wrapper. §2 stays the
5050
* control: a refusal keeps its sentence on the same rows.
51+
*
52+
* [#22885] …and with the sandbox's OWN fault, one shape per kind the runner
53+
* raises itself (`isSandboxOwnFault`), held to the same answer. Measured
54+
* before the repair: twenty-three of these rows relayed the runner's text at
55+
* their own terminal, while the crash and declared-5xx shapes, the CONTROL,
56+
* already read the doors' answer on every row and are unchanged by it.
5157
*/
5258

53-
import { describe, it, expect } from 'vitest';
59+
import { describe, it, expect, vi, beforeAll, afterAll, type MockInstance } from 'vitest';
5460
import { RestServer } from './rest-server.js';
5561
import { mountAndRecordDirectRoutes } from './direct-mount-composition.js';
5662
import { REST_ROUTE_LEDGER } from './rest-route-ledger.js';
63+
import { sandboxFaultAnswer } from './error-response.js';
64+
import { isSandboxOwnFault } from '@objectstack/types';
5765

5866
const SENTENCE = 'Locked rows cannot be changed here.';
5967
const WRAPPER = `hook 'mz_guard' threw: Error: ${SENTENCE}`;
@@ -563,26 +571,80 @@ describe('[#22719] §5 the repaired rows: a fault keeps its own terminal, a refu
563571
// ─── [#22764] the fault side: every driven row answers as the doors do ──────
564572

565573
/**
566-
* The two sandbox shapes the doors answer as FAULTS, the status and code they
574+
* [#22885] The sandbox's OWN fault: a `SandboxError` the runner raised itself,
575+
* not one a body threw. Its producer (`runtime/src/sandbox/quickjs-runner.ts`)
576+
* names the class `'SandboxError'` and leaves `innerMessage` unset for "the
577+
* sandbox's own internal errors (capability denials, timeouts, marshalling
578+
* failures)", so `isSandboxOwnFault` (`@objectstack/types`) answers `true` and
579+
* the doors answer it as a fault (#22868).
580+
*
581+
* Built from that constructor, as {@link sandboxRefusal} is, because this
582+
* package does not depend on `@objectstack/runtime` and no factory exports the
583+
* runner's raise sites: their census lives inside `@objectstack/core`'s
584+
* `import-runner-sandbox-fault-row.test.ts`. One shape per kind that census
585+
* names, each message rendered from its raise site's template.
586+
*/
587+
function sandboxOwnFault(message: string): () => Error {
588+
return () => {
589+
const err = new Error(message);
590+
err.name = 'SandboxError';
591+
return err;
592+
};
593+
}
594+
595+
const OWN_FAULT = { status: 500, code: 'INTERNAL_ERROR' } as const;
596+
597+
/**
598+
* The sandbox shapes the doors answer as FAULTS, the status and code they
567599
* answer each with, and the text that must not reach the caller: the native
568-
* error a crash carries, and the prose a declared 5xx withholds.
600+
* error a crash carries, and the prose a declared 5xx withholds. [#22885] The
601+
* CONTROL for {@link SANDBOX_OWN_FAULTS}: unchanged by that card.
569602
*/
570-
const SANDBOX_FAULTS = [
603+
const SANDBOX_FAULT_CONTROLS = [
571604
['a body that CRASHED', sandboxCrash, { status: 500, code: 'INTERNAL_ERROR' }, /TypeError|boom/],
572605
['a refusal that declared a 5xx', sandboxDeclared5xx, { status: 503, code: 'SERVICE_UNAVAILABLE' }, /ledger/],
573606
] as const;
574607

608+
/** [#22885] The runner's own faults, one per kind, and the diagnostic text each must not relay. */
609+
const SANDBOX_OWN_FAULTS = [
610+
['the sandbox\'s own fault: CPU budget',
611+
sandboxOwnFault("hook 'mz_guard' exceeded CPU budget of 50ms (after 3 pump iterations)"), OWN_FAULT, /budget/],
612+
['the sandbox\'s own fault: wall-clock ceiling',
613+
sandboxOwnFault("hook 'mz_guard' exceeded wall-clock ceiling of 30000ms while awaiting host calls (after 3 pump iterations)"),
614+
OWN_FAULT, /ceiling/],
615+
['the sandbox\'s own fault: a capability denial naming the object',
616+
sandboxOwnFault(`capability 'api.read' not granted to hook 'mz_guard' (called ctx.api.object('${OBJECT}').find)`),
617+
OWN_FAULT, /capability|granted/],
618+
['the sandbox\'s own fault: ctx.api unavailable',
619+
sandboxOwnFault("ctx.api unavailable in hook 'mz_guard'"), OWN_FAULT, /unavailable/],
620+
['the sandbox\'s own fault: an install failure',
621+
sandboxOwnFault('failed to install ctx.api.transaction: SyntaxError: unexpected token'), OWN_FAULT, /install|SyntaxError/],
622+
['the sandbox\'s own fault: a marshal failure',
623+
sandboxOwnFault('failed to marshal host value: SyntaxError: unexpected token'), OWN_FAULT, /marshal|SyntaxError/],
624+
['the sandbox\'s own fault: a nested transaction refusal',
625+
sandboxOwnFault('nested ctx.api.transaction is not supported'), OWN_FAULT, /transaction|supported/],
626+
['the sandbox\'s own fault: a missing ctx.api method naming the object',
627+
sandboxOwnFault(`ctx.api.object('${OBJECT}').find not implemented`), OWN_FAULT, /implemented|ctx\.api/],
628+
['the sandbox\'s own fault: a nested body\'s crash relayed one VM hop up',
629+
sandboxOwnFault("hook 'mz_inner' threw: TypeError: boom"), OWN_FAULT, /TypeError|boom/],
630+
] as const;
631+
632+
const SANDBOX_FAULTS = [...SANDBOX_FAULT_CONTROLS, ...SANDBOX_OWN_FAULTS] as const;
633+
const OWN_FAULT_SHAPES: ReadonlySet<unknown> = new Set(SANDBOX_OWN_FAULTS);
634+
575635
/**
576636
* Driven rows that answer a sandboxed fault at their OWN terminal rather than
577637
* the doors' status and code, with the reason. Closed. The words are withheld
578638
* on both all the same, and §6 still holds them to the doors' sentence.
639+
* [#22885] The own-fault shapes take the same exception: the family's sentence
640+
* is `sandboxFaultAnswer`'s, its status and code its own, as for a crash.
579641
*/
580642
const OWN_FAULT_TERMINAL: ReadonlyMap<string, { status: number; code: string; reason: string }> = new Map([
581643
['POST /api/v1/data/:object/:id/shares', {
582644
status: 500, code: 'SHARE_GRANT_FAILED',
583-
reason: "the record-share family withholds a sandbox's words at its own 500 (`sharingFaultMessage`) and "
584-
+ 'folds a declared 5xx into that terminal by a recorded choice (#11683, #12693); neither is a '
585-
+ 'leak, and its status and code are a decision this card does not take',
645+
reason: "the record-share family withholds a sandbox's words at its own 500 (`sharingFaultMessage`, which "
646+
+ "reads `sandboxFaultAnswer`'s sentence) and folds a declared 5xx into that terminal by a recorded "
647+
+ 'choice (#11683, #12693); neither is a leak, and its status and code are a decision this card does not take',
586648
}],
587649
['DELETE /api/v1/data/:object/:id/shares/:shareId', {
588650
status: 500, code: 'SHARE_REVOKE_FAILED',
@@ -591,6 +653,20 @@ const OWN_FAULT_TERMINAL: ReadonlyMap<string, { status: number; code: string; re
591653
]);
592654

593655
describe('[#22764] §6 every driven write row answers a sandboxed fault as the doors do, its words withheld', () => {
656+
// [#22885] §6 observes the REST fault log: a withheld own fault's words must
657+
// still reach the operator. 'info' is the SHIPPED default, declared here
658+
// as `scripts/check-rest-log-spy-declared.mjs` requires; the spy swallows
659+
// the output.
660+
let faultLog: MockInstance<typeof console.error>;
661+
beforeAll(() => {
662+
vi.stubEnv('OS_REST_LOG', 'info');
663+
faultLog = vi.spyOn(console, 'error').mockImplementation(() => {});
664+
});
665+
afterAll(() => {
666+
faultLog.mockRestore();
667+
vi.unstubAllEnvs();
668+
});
669+
594670
it('the reference: the `/data` door answers each shape at the pinned status and code', async () => {
595671
for (const [label, error, doors, leak] of SANDBOX_FAULTS) {
596672
const res = await drive(boot(error), 'POST /api/v1/data/:object', { name: 'x' });
@@ -600,15 +676,26 @@ describe('[#22764] §6 every driven write row answers a sandboxed fault as the d
600676
}
601677
}, 60_000);
602678

679+
it('[#22885] the own-fault shapes are the shared predicate\'s, read off the producer and never its words', () => {
680+
for (const [label, error] of SANDBOX_OWN_FAULTS) {
681+
expect(isSandboxOwnFault(error()), label).toBe(true);
682+
// CONTROL: the same words thrown by anything else are not the answer's.
683+
expect(sandboxFaultAnswer(new Error(error().message)), label).toBeUndefined();
684+
}
685+
for (const [label, error] of SANDBOX_FAULT_CONTROLS) expect(isSandboxOwnFault(error()), label).toBe(false);
686+
});
687+
603688
it('every row naming its own terminal is a driven row', () => {
604689
for (const route of OWN_FAULT_TERMINAL.keys()) expect(Object.keys(ANSWERS_THE_SENTENCE), route).toContain(route);
605690
});
606691

607692
for (const [route, driver] of Object.entries(ANSWERS_THE_SENTENCE)) {
608-
for (const [label, error, doors, leak] of SANDBOX_FAULTS) {
693+
for (const shape of SANDBOX_FAULTS) {
694+
const [label, error, doors, leak] = shape;
609695
it(`${route}: ${label}`, async () => {
610696
const door = await drive(boot(error), 'POST /api/v1/data/:object', { name: 'x' });
611697
const b = boot(error, driver.protocol?.(error));
698+
faultLog.mockClear();
612699
const res = await drive(b, route, driver.body ?? {});
613700
// Read first: the async job's worker writes after the `201`.
614701
const answer = driver.answer ? await driver.answer(res, b) : sentenceOf(res.body);
@@ -624,6 +711,12 @@ describe('[#22764] §6 every driven write row answers a sandboxed fault as the d
624711
const expected = OWN_FAULT_TERMINAL.get(route) ?? doors;
625712
expect(res.statusCode).toBe(expected.status);
626713
expect(codeOf(res.body)).toBe(expected.code);
714+
if (!OWN_FAULT_SHAPES.has(shape)) return;
715+
// [#22885] The whole error, not a summary of it, reaches the log.
716+
const runnerText = error().message;
717+
const logged = faultLog.mock.calls.some((args) => args.some((arg) =>
718+
arg instanceof Error && arg.name === 'SandboxError' && arg.message === runnerText));
719+
expect(logged, 'the runner\'s error reaches the server log').toBe(true);
627720
}, 60_000);
628721
}
629722
}

0 commit comments

Comments
 (0)