Skip to content

Commit 1aa4dd1

Browse files
fix(mcp): the record data tools answer a code-less throw as the /data door does, so an undeclared hook abort is HOOK_ABORTED (#31245)
<!-- claude-projects-attribution: {"github_login":"marchtian"} --> _Requested by **田騏** · [project thread](https://claude.ai/code/project/chan_01VmuUhXU9SSUzqJYyXSmYSi?thread=cmsg_01VmuUhXU9SSUzqJYyXSmYSi3qHENwt3pGirMrGoMekDtd)_ Fixes #31218 Clause-②: yes Metadata-protocol: none ## 维护者速读 - 改了什么:MCP 的六个记录数据工具(`query_records`、`aggregate_records`、`get_record`、`create_record`、`update_record`、`delete_record`)遇到不带错误码的异常时,改用 `/api/v1/data` 同一个分类函数 `mapDataError` 来回答:沙箱 hook 的业务中止回 `HOOK_ABORTED` + hook 自己写的那句话;代码 hook 的裸 throw、崩溃、驱动错误回 `500 INTERNAL_ERROR`,原文不再给 agent,改为在服务端打一条 `[5xx]` 日志。带错误码的异常照旧。 - 为什么改:之前 agent 看到的是沙箱调试包装 `hook 'NAME' threw: Error: …`,没有错误码,还暴露 hook 内部名字;同一个中止在 REST 上是 `400 HOOK_ABORTED`。agent 分不清"业务拒绝"和"故障",场景跑分也读不到码。 - 风险与回滚:这六个工具对无码异常的回答从纯文本变成 JSON 信封 `{ error: { code, message, status } }`,且 5xx 原文被隐去(与 REST 一致);只改 `@objectstack/mcp`,patch。回滚即还原 `mcp-http-tools.ts`。 - 先看:`packages/mcp/src/mcp-http-tools.ts` 的 `dataDoorErrorResult` / `dataDoorAnswerOf`;`packages/mcp/src/mcp-coded-refusal.test.ts` 末尾的 #31218 块。 - 两分钟验证:`pnpm --filter @objectstack/mcp exec vitest run src/mcp-coded-refusal.test.ts -t 31218`,预期 16 个用例全部通过。 - 需要你做的:无,席位落地。 ## What changes The record data tools of `packages/mcp/src/mcp-http-tools.ts` rendered a thrown error that carries no string `code` as its bare message (`errorResult(messageOf(err))`). A QuickJS hook body's `throw new Error('…')` arrives as a `SandboxError` with no `code`, whose `.message` is the `hook 'NAME' threw: Error: …` debug wrapper, so the agent read the wrapper (hook name included) and no `ErrorCode`, while `/api/v1/data` answers the same throw `400 HOOK_ABORTED` with the inner sentence. - **`dataDoorErrorResult(err, tool, objectName)`**, the catch of `query_records`, `aggregate_records`, `get_record`, and (through `landedWriteResultOrError`'s `otherwise`, now a required argument) `create_record`, `update_record`, `delete_record`: - a coded throw renders through `thrownRefusalEnvelope` exactly as before (read first); - a code-less throw renders the `/data` door's answer, `{ "error": { "code", "message", "status" } }`, read off `mapDataError(err, objectName)` (`@objectstack/types`) by `dataDoorAnswerOf`. No second classification exists in `packages/mcp`; - a 5xx answer is logged once through `logServerFault` (`[5xx] 500 tools/call update_record task — THROWN-MESSAGE`), since its message no longer reaches the agent. - **`rowRefusalOf`** (the per-row grouping of `delete_records`, #30891) reads the same `dataDoorAnswerOf` instead of its own inline read of `mapDataError`; its answers are unchanged. - **`codedErrorResult`** accepts an envelope without `code`, for a throw that declared a status and no code (the door invents none). - **No change** to `@objectstack/types`, `packages/spec`, the other tools, or the coded path. The family, each answered as the door answers it (pinned at the MCP door, each asserted literally AND equal to `mapDataError` on the same throw): | Throw | Before (MCP) | Now (MCP) = `/data` | |---|---|---| | sandboxed hook abort, no code (`SandboxError`, `innerMessage`) | text `hook 'demoleave_leave_request_guard' threw: Error: Not enough annual leave remaining` | `HOOK_ABORTED` · 400 · `Not enough annual leave remaining` | | the same abort with the `SandboxError` instance lost (wrapper text only) | the wrapper text | `HOOK_ABORTED` · 400 · inner sentence | | sandboxed abort that declared `status: 422`, no code | the wrapper text | `HOOK_ABORTED` · 422 | | code hook's bare `throw new Error(…)` | its message | `INTERNAL_ERROR` · 500 · `Internal server error` (message logged) | | sandboxed body that crashed (`TypeError: …`) | the wrapper text | `INTERNAL_ERROR` · 500 | | runtime `callData`'s `{ statusCode: 503, message: 'Data service not available' }` | its message | no code · 503 · `Internal server error` (message logged) | | `{ statusCode: 400, message }`, no code | its message | no code · 400 · its message | | coded refusal (`RECORD_LOCKED` / 409) — CONTROL | coded envelope | unchanged, not logged | ## Tests - `packages/mcp/src/mcp-coded-refusal.test.ts`: a new top-level describe (#31218), in the shared `/mcp` HTTP harness (`rpcOverHttp`, `bridgeThrowing`): the undeclared abort for each of the six tools (hook name and `threw` absent from the text), the lost-instance wrapper, the declared-4xx abort, the 4xx-is-not-logged pin, the bare throw (message withheld, one `[5xx]` line naming tool and object), the crash, the 503, the declared 400 with no code, the coded CONTROL on every data tool, and a CONTROL that the other tools still answer a code-less throw with its bare message. The #23509 table's "code-less throw answers its bare message" row now splits: data tools answer the door's 500, the rest are unchanged. The "not a code" row moves to `list_actions`, which still renders that case. - `packages/mcp/src/mcp-server-runtime.http.test.ts`: two pins that threw an uncoded `Error` read the new answer. The aggregate pin's fixture becomes the coded `PermissionDeniedError` the security plugin really throws for that denial (`security-plugin-authz-middleware.ts`). <details><summary>Base re-measure (order step 1) and ablation</summary> Base re-measure: `mcp-http-tools.ts` restored to the base blob (2b752f1, hash match checked), the #31218 block run: `Tests 13 failed | 3 passed | 46 skipped (62)`. Each data tool's text was `hook 'demo…` (`SyntaxError: Unexpected token 'h', "hook 'demo"... is not valid JSON`, ×5 tools + the lost-instance row), and the 503 / bare / 400 rows answered `Data servi…` / `Not enough…` / `aggregate …` as bare text. Restored with `git checkout HEAD --`, `git diff HEAD` empty. Ablation (one-time, `node scripts/ablation-replace.mjs`): in `dataDoorErrorResult`, `return codedErrorResult(door);` replaced by `return errorResult(messageOf(err));`. `ok mutation landed: anchor 1 -> 0, blob 15cb855 -> 62f6a65d8a2b`; `src/mcp-coded-refusal.test.ts`: `Tests 19 failed | 43 passed (62)` (the 13 rows above plus the 6 table rows); `ok restored: blob == HEAD (15cb855) and git diff HEAD is empty`. The test imports the package's source, so no `dist/` is involved. </details> ## Acceptance notes - **Still answered differently from REST, not changed here (listed for the seat):** - a CODED sandboxed refusal (`throw` with `code` + `status` in a hook body) answers the wrapper text with the hook name, `{ code: 'RECORD_LOCKED', message: "hook 'guard' threw: Error: Locked.", status: 409 }`, measured on `MCPServerRuntime`; `/data` answers the inner sentence. `thrownRefusalEnvelope` reads `.message`; the order kept the coded path unchanged. - the metadata tools (`list_objects`, `describe_object`, `validate_expression`), `list_actions`, the record resource, `run_action` / `resume_run` and `delete_records`' outer catch still render a code-less throw as its bare message. `delete_records`' outer catch also renders this file's own code-less sentences, which the door's message heuristics would misread (its "could not read the records" sentence names the object and contains `not`, the unknown-object limb), so it keeps `errorResultWithCode`. - `delete_records`' fault answer still quotes the faulting row's thrown message. - a driver's own string `code` (`SQLITE_BUSY`) rides the coded path with its message, where `/data` answers `500 INTERNAL_ERROR`. - The 5xx log line is `logServerFault`'s, with `request: { method: 'tools/call', path: 'TOOL OBJECT' }` as its coordinates; the MCP tool layer has no HTTP request in hand. ## Verification Merged `origin/main` once before opening (a0f8cfb; the merge touched `packages/lint` and `plugin-auth` tests only). - `pnpm --filter @objectstack/mcp test` on a0f8cfb: `Test Files 60 passed (60)`, `Tests 789 passed (789)`, lock `VERDICT command-exit 0`. - `pnpm --filter @objectstack/mcp typecheck`: exit 0 (includes `check:test-typecheck`, 53 errors / 8 pinned signatures held). - `pnpm exec eslint --no-inline-config` on the three touched `.ts` files: exit 0 (`@objectstack/mcp` has no `lint` script). <details><summary>Derived gates (node scripts/pm/dispatch-gates.mjs --commands, 60 commands, all on a0f8cfb)</summary> - `node scripts/check-adr-0087-registration.mjs --base origin/main` :: exit 0 - `node scripts/check-adr-0087-registration.mjs --self-test` :: exit 0 - `node scripts/check-changeset-no-major.mjs --base origin/main` :: exit 0 - `node scripts/check-closing-keyword-parity.mjs` :: exit 0 - `node scripts/check-closing-keyword-parity.mjs --self-test` :: exit 0 - `node scripts/check-comment-mask-adoption.mjs` :: exit 0 - `node scripts/check-comment-mask-adoption.mjs --self-test` :: exit 0 - `node scripts/check-comment-mask-corpus.mjs` :: exit 0 - `node scripts/check-dts-emitted.mjs --self-test` :: exit 0 - `node scripts/check-empty-changeset.mjs --base origin/main` :: exit 0 - `node scripts/check-empty-changeset.mjs --self-test` :: exit 0 - `node scripts/check-issue-citations.mjs` :: exit 0 - `node scripts/check-plugin-teardown-shape.mjs` :: exit 0 - `node scripts/check-plugin-teardown-shape.mjs --self-test` :: exit 0 - `node scripts/ci/assert-turbo-remote-cache.mjs --self-test` :: exit 0 - `node scripts/ci/plan-test-core-shards.mjs --self-test` :: exit 0 - `node scripts/ci/prune-turbo-cache.mjs --self-test` :: exit 0 - `node scripts/ci/snapshot-next.mjs --self-test` :: exit 0 - `node scripts/docs-audit/check-affected-docs.mjs` :: exit 0 - `node scripts/docs-audit/check-drift-comment.mjs` :: exit 0 - `node scripts/pm/release-rehearsal-clone.mjs --self-test` :: exit 0 - `node scripts/release-pending-publish.mjs --self-test` :: exit 0 - `pnpm --filter @objectstack/spec run check:duration-unit-keys` :: exit 0 - `pnpm check:changeset-gate-self-tests` :: exit 0 - `pnpm check:ci-filter-parity` :: exit 0 - `pnpm check:closed-ceremony-text` :: exit 0 - `pnpm check:cross-package-test-inputs` :: exit 0 - `pnpm check:dispatcher-error-vocabulary` :: exit 0 - `pnpm check:doc-authoring` :: exit 0 - `pnpm check:driver-memory-census` :: exit 0 - `pnpm check:dts-closure` :: exit 0 - `pnpm check:dual-build-cjs-loads` :: exit 0 - `pnpm check:engine-double-contract` :: exit 0 - `pnpm check:error-status-conformance` :: exit 0 - `pnpm check:gitlink-declared` :: exit 0 - `pnpm check:issue-citations` :: exit 0 - `pnpm check:keyed-text-bounds` :: exit 0 - `pnpm check:lean-entry-closure` :: exit 0 - `pnpm check:logger-receiver-detach` :: exit 0 - `pnpm check:nul-bytes` :: exit 0 - `pnpm check:objectql-double-limit` :: exit 0 - `pnpm check:org-identifier` :: exit 0 - `pnpm check:page-declaration-shape` :: exit 0 - `pnpm check:platform-object-tenancy-census` :: exit 0 - `pnpm check:published-files` :: exit 0 - `pnpm check:query-options-erasure` :: exit 0 - `pnpm check:refd-timer-probe` :: exit 0 - `pnpm check:registry-log-declared` :: exit 0 - `pnpm check:rest-log-spy-declared` :: exit 0 - `pnpm check:slot-lookup` :: exit 0 - `pnpm check:sourcemap-no-sources-content` :: exit 0 - `pnpm check:system-context-census` :: exit 0 - `pnpm check:test-source-alias` :: exit 0 - `pnpm check:tier-file-adoption` :: exit 0 - `pnpm check:type-check-coverage` :: exit 0 - `pnpm check:type-check-debt` :: exit 0 - `pnpm check:undeclared-dep-imports` :: exit 0 - `pnpm check:vouch-for-reference-callers` :: exit 0 - `pnpm check:watch-hint-literal` :: exit 0 - `pnpm check:where-matcher` :: exit 0 `node scripts/pm/dispatch-gates.mjs --ran`: `Run reconciliation — 60 derived, 60 run, 0 NOT-MEASURED, 0 UNRUN.` Not run locally (CI's): `test:repo`, the dogfood shards (queue only), the type-check lanes. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (Not touched.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (eslint on the touched files.) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (Not touched.) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_017ihL2eNMbdYCpXSovm5QP5)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 169ff7e commit 1aa4dd1

4 files changed

Lines changed: 318 additions & 33 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/mcp': patch
3+
---
4+
5+
fix(mcp): the record data tools answer a code-less throw as the `/api/v1/data` door does — an undeclared sandboxed hook abort is `HOOK_ABORTED` with the hook's own sentence
6+
7+
Clause-②: yes
8+
Metadata-protocol: none
9+
10+
`query_records`, `aggregate_records`, `get_record`, `create_record`, `update_record` and `delete_record` used to answer a thrown error that carried no `code` with its bare message. For a sandboxed hook body's `throw new Error('…')` that message is the sandbox's debug wrapper, `hook '<name>' threw: Error: …`, so an agent saw the hook's internal name and no code, while `/api/v1/data` answers the same abort `400 HOOK_ABORTED` with the body's sentence alone.
11+
12+
These six tools now classify a code-less throw with the `/data` door's own classifier (`mapDataError`, `@objectstack/types`) and answer `{ "error": { "code", "message", "status" } }`:
13+
14+
- an undeclared sandboxed hook abort: `HOOK_ABORTED`, status 400 (or the 4xx the body declared), the body's sentence;
15+
- a code hook's bare `throw new Error(…)`, a crash, a driver error: `INTERNAL_ERROR`, status 500, message `Internal server error`. The thrown message no longer reaches the agent; it is logged once on the server as a `[5xx]` line, as the HTTP doors log a fault;
16+
- a throw that declared a status and no code (for example the runtime's `503` when no data service is registered): that status, no code, and for a 5xx the message withheld.
17+
18+
An agent that read the bare text of these errors reads `error.message` of the JSON envelope instead, and branches on `error.code`. A thrown error that carries a `code` is answered exactly as before. The other tools (`list_objects`, `describe_object`, `validate_expression`, `list_actions`, `delete_records`, `run_action`, `resume_run` and the file and datasource tools) are unchanged.

‎packages/mcp/src/mcp-coded-refusal.test.ts‎

Lines changed: 181 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,11 @@
4545
* `plugin.record-resource-exposure.test.ts`.
4646
*/
4747

48-
import { describe, it, expect, vi } from 'vitest';
48+
import { describe, it, expect, vi, afterEach } from 'vitest';
4949
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
5050
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
5151
import { recordNotFoundError } from '@objectstack/core';
52+
import { mapDataError } from '@objectstack/types';
5253
import type { IMetadataService } from '@objectstack/spec/contracts';
5354

5455
import { MCPServerRuntime } from './mcp-server-runtime.js';
@@ -84,24 +85,35 @@ interface Site {
8485
* change. The `details` pin below covers every other row.
8586
*/
8687
actionDoor?: true;
88+
/**
89+
* [#31218] A record data tool: a code-less throw renders the `/data` door's
90+
* answer (`dataDoorErrorResult`), not its bare message.
91+
*/
92+
dataDoor?: true;
8793
}
8894

8995
/** Every tool `wireBridgeTools` registers for {@link benignBridge}, each with the member it calls first. */
9096
const SITES: readonly Site[] = [
9197
{ tool: 'list_objects', args: {}, member: 'listObjects' },
9298
{ tool: 'describe_object', args: { objectName: 'task' }, member: 'describeObject' },
9399
{ tool: 'validate_expression', args: { objectName: 'task', expression: 'record.title' }, member: 'describeObject' },
94-
{ tool: 'query_records', args: { objectName: 'task' }, member: 'query' },
100+
{ tool: 'query_records', args: { objectName: 'task' }, member: 'query', dataDoor: true },
95101
{
96102
tool: 'aggregate_records',
97103
args: { objectName: 'task', aggregations: [{ function: 'count', alias: 'n' }] },
98104
member: 'aggregate',
105+
dataDoor: true,
106+
},
107+
{ tool: 'get_record', args: { objectName: 'task', recordId: 't9' }, member: 'get', dataDoor: true },
108+
{ tool: 'create_record', args: { objectName: 'task', data: { title: 'x' } }, member: 'create', dataDoor: true },
109+
{
110+
tool: 'update_record',
111+
args: { objectName: 'task', recordId: 't9', data: { title: 'x' } },
112+
member: 'update',
113+
dataDoor: true,
99114
},
100-
{ tool: 'get_record', args: { objectName: 'task', recordId: 't9' }, member: 'get' },
101-
{ tool: 'create_record', args: { objectName: 'task', data: { title: 'x' } }, member: 'create' },
102-
{ tool: 'update_record', args: { objectName: 'task', recordId: 't9', data: { title: 'x' } }, member: 'update' },
103115
// `confirm: true` passes the confirmation gate, so the call reaches `remove`.
104-
{ tool: 'delete_record', args: { objectName: 'task', recordId: 't9', confirm: true }, member: 'remove' },
116+
{ tool: 'delete_record', args: { objectName: 'task', recordId: 't9', confirm: true }, member: 'remove', dataDoor: true },
105117
// [#30891] The selection read is the member a bulk delete calls first; a
106118
// refusal there refuses the whole call, rendered as the data tools render it.
107119
{ tool: 'delete_records', args: { objectName: 'task', where: {}, confirm: true }, member: 'query' },
@@ -178,10 +190,23 @@ describe('MCP tools over HTTP /mcp: a coded refusal carries its code (#23509)',
178190
});
179191
});
180192

181-
it(`${site.tool}: a code-less throw from ${String(site.member)} answers its bare message, as before`, async () => {
182-
const result = await callTool(site, new Error('engine exploded'));
183-
expect(result).toEqual({ content: [{ type: 'text', text: 'engine exploded' }], isError: true });
184-
});
193+
if (site.dataDoor) {
194+
// [#31218] The record data tools answer the `/data` door's classification
195+
// of a code-less throw; that door answers this one `500 INTERNAL_ERROR`
196+
// and withholds its message. The cases live in the #31218 block below.
197+
it(`${site.tool}: a code-less throw from ${String(site.member)} answers the /data door's 500, its message withheld`, async () => {
198+
const result = await callTool(site, new Error('engine exploded'));
199+
expect(result.isError).toBe(true);
200+
expect(JSON.parse(result.content[0].text)).toEqual({
201+
error: { code: 'INTERNAL_ERROR', message: 'Internal server error', status: 500 },
202+
});
203+
});
204+
} else {
205+
it(`${site.tool}: a code-less throw from ${String(site.member)} answers its bare message, as before`, async () => {
206+
const result = await callTool(site, new Error('engine exploded'));
207+
expect(result).toEqual({ content: [{ type: 'text', text: 'engine exploded' }], isError: true });
208+
});
209+
}
185210

186211
if (!site.actionDoor) {
187212
it(`${site.tool}: a PERMISSION_DENIED answers code, message and status, and WITHHOLDS its details`, async () => {
@@ -201,6 +226,9 @@ describe('MCP tools over HTTP /mcp: a coded refusal carries its code (#23509)',
201226

202227
// One site is enough below: every catch renders through the one reader.
203228
const query = SITES.find((s) => s.tool === 'query_records')!;
229+
// [#31218] …for the code-less throws, one site that is not a record data
230+
// tool: those classify a code-less throw as the `/data` door does.
231+
const listActions = SITES.find((s) => s.tool === 'list_actions')!;
204232

205233
// [#23528] Moved to the coded rendering. This row pinned the gate's old
206234
// `{ statusCode: 404, message }` as its bare message. The gate now throws
@@ -242,7 +270,7 @@ describe('MCP tools over HTTP /mcp: a coded refusal carries its code (#23509)',
242270
'a thrown string',
243271
]) {
244272
const text = thrown instanceof Error ? thrown.message : String(thrown);
245-
expect(await callTool(query, thrown), text).toEqual({ content: [{ type: 'text', text }], isError: true });
273+
expect(await callTool(listActions, thrown), text).toEqual({ content: [{ type: 'text', text }], isError: true });
246274
}
247275
});
248276

@@ -254,6 +282,148 @@ describe('MCP tools over HTTP /mcp: a coded refusal carries its code (#23509)',
254282
});
255283
});
256284

285+
/**
286+
* [#31218] The record data tools classify a CODE-LESS throw as the
287+
* `/api/v1/data` door does: through that door's own `mapDataError`
288+
* (`@objectstack/types`), so the two doors give one answer for one throw.
289+
*
290+
* Each case asserts the literal wire answer AND that it equals the door's
291+
* (`mapDataError` on the same throw), so the pin cannot pass by both sides
292+
* agreeing on something nobody wrote down, nor by the door moving alone.
293+
*/
294+
describe('the record data tools: a code-less throw answers what the /data door answers (#31218)', () => {
295+
const DATA_SITES = SITES.filter((s) => s.dataDoor);
296+
297+
afterEach(() => {
298+
vi.restoreAllMocks();
299+
});
300+
301+
/** The shape `runtime/src/sandbox/quickjs-runner.ts` throws for a hook body's own `throw`, as measured on the card. */
302+
function sandboxAbort(inner: string, extra: Record<string, unknown> = {}): Error {
303+
const err = new Error(`hook 'demoleave_leave_request_guard' threw: Error: ${inner}`);
304+
err.name = 'SandboxError';
305+
return Object.assign(err, { innerMessage: inner }, extra);
306+
}
307+
308+
/** The tool's answer, parsed, after asserting it is a tool error. */
309+
async function errorOf(site: Site, thrown: unknown): Promise<{ text: string; error: Record<string, unknown> }> {
310+
const result = await callTool(site, thrown);
311+
expect(result.isError).toBe(true);
312+
expect(result.content).toHaveLength(1);
313+
const text = String(result.content[0].text);
314+
return { text, error: JSON.parse(text).error };
315+
}
316+
317+
/** The `/data` door's answer to the same throw, in the tool's envelope spelling. */
318+
function doorAnswer(thrown: unknown): Record<string, unknown> {
319+
const door = mapDataError(thrown, 'task');
320+
return {
321+
...(typeof door.body.code === 'string' ? { code: door.body.code } : {}),
322+
message: door.body.error,
323+
status: door.status,
324+
};
325+
}
326+
327+
it('the six record data tools are the rows this block drives', () => {
328+
expect(DATA_SITES.map((s) => s.tool).sort()).toEqual(
329+
['aggregate_records', 'create_record', 'delete_record', 'get_record', 'query_records', 'update_record'],
330+
);
331+
});
332+
333+
for (const site of DATA_SITES) {
334+
it(`${site.tool}: an undeclared sandboxed hook abort answers HOOK_ABORTED with the inner sentence, no wrapper, no hook name`, async () => {
335+
const thrown = sandboxAbort('Not enough annual leave remaining');
336+
const { text, error } = await errorOf(site, thrown);
337+
expect(error).toEqual({ code: 'HOOK_ABORTED', message: 'Not enough annual leave remaining', status: 400 });
338+
expect(error).toEqual(doorAnswer(thrown));
339+
expect(text).not.toContain('demoleave_leave_request_guard');
340+
expect(text).not.toContain('threw');
341+
});
342+
}
343+
344+
const update = DATA_SITES.find((s) => s.tool === 'update_record')!;
345+
346+
it('an abort whose SandboxError instance was lost (the wrapper text alone) answers HOOK_ABORTED too, as the door does', async () => {
347+
const thrown = new Error("hook 'demoleave_leave_request_guard' threw: Error: Not enough annual leave remaining");
348+
const { text, error } = await errorOf(update, thrown);
349+
expect(error).toEqual({ code: 'HOOK_ABORTED', message: 'Not enough annual leave remaining', status: 400 });
350+
expect(error).toEqual(doorAnswer(thrown));
351+
expect(text).not.toContain('demoleave_leave_request_guard');
352+
});
353+
354+
it('an abort that declared a 4xx status and no code keeps that status, with HOOK_ABORTED', async () => {
355+
const thrown = sandboxAbort('Closed.', { status: 422 });
356+
const { error } = await errorOf(update, thrown);
357+
expect(error).toEqual({ code: 'HOOK_ABORTED', message: 'Closed.', status: 422 });
358+
expect(error).toEqual(doorAnswer(thrown));
359+
});
360+
361+
it('a 4xx refusal is not logged as a server fault', async () => {
362+
const log = vi.spyOn(console, 'error').mockImplementation(() => {});
363+
await errorOf(update, sandboxAbort('Not enough annual leave remaining'));
364+
expect(log).not.toHaveBeenCalled();
365+
});
366+
367+
it("a code hook's bare throw answers 500 INTERNAL_ERROR, its message withheld from the agent and logged once for the operator", async () => {
368+
const log = vi.spyOn(console, 'error').mockImplementation(() => {});
369+
const thrown = new Error('Not enough annual leave remaining (code hook)');
370+
const { text, error } = await errorOf(update, thrown);
371+
expect(error).toEqual({ code: 'INTERNAL_ERROR', message: 'Internal server error', status: 500 });
372+
expect(error).toEqual(doorAnswer(thrown));
373+
expect(text).not.toContain('code hook');
374+
expect(log).toHaveBeenCalledTimes(1);
375+
const line = String(log.mock.calls[0]![0]);
376+
expect(line).toMatch(/^\[5xx\] 500 tools\/call update_record task — /);
377+
expect(line).toContain('Not enough annual leave remaining (code hook)');
378+
});
379+
380+
it('a sandboxed body that CRASHED is a fault, not an abort: 500 INTERNAL_ERROR, no wrapper', async () => {
381+
vi.spyOn(console, 'error').mockImplementation(() => {});
382+
const thrown = sandboxAbort('TypeError: not a function');
383+
const { text, error } = await errorOf(update, thrown);
384+
expect(error).toEqual({ code: 'INTERNAL_ERROR', message: 'Internal server error', status: 500 });
385+
expect(error).toEqual(doorAnswer(thrown));
386+
expect(text).not.toContain('demoleave_leave_request_guard');
387+
});
388+
389+
it("the runtime's `{ statusCode: 503, message }` (no data service) answers 503 with no code and its prose withheld", async () => {
390+
const log = vi.spyOn(console, 'error').mockImplementation(() => {});
391+
const thrown = { statusCode: 503, message: 'Data service not available' };
392+
const { text, error } = await errorOf(update, thrown);
393+
expect(error).toEqual({ message: 'Internal server error', status: 503 });
394+
expect(error).toEqual(doorAnswer(thrown));
395+
expect(text).not.toContain('Data service not available');
396+
expect(log).toHaveBeenCalledTimes(1);
397+
});
398+
399+
it('a throw that declared a 4xx and no code answers that status and its own sentence, with no code invented', async () => {
400+
const thrown = { statusCode: 400, message: 'aggregate requires at least one aggregation' };
401+
const { error } = await errorOf(update, thrown);
402+
expect(error).toEqual({ message: 'aggregate requires at least one aggregation', status: 400 });
403+
expect(error).toEqual(doorAnswer(thrown));
404+
});
405+
406+
it('CONTROL: a coded refusal renders exactly as the coded reader renders it, whatever the door would say', async () => {
407+
const log = vi.spyOn(console, 'error').mockImplementation(() => {});
408+
for (const site of DATA_SITES) {
409+
const thrown = Object.assign(new Error('Record is locked'), { code: 'RECORD_LOCKED', status: 409 });
410+
const { error } = await errorOf(site, thrown);
411+
expect(error, site.tool).toEqual({ code: 'RECORD_LOCKED', message: 'Record is locked', status: 409 });
412+
}
413+
expect(log).not.toHaveBeenCalled();
414+
});
415+
416+
it('CONTROL: the tools that are not record data tools still answer a code-less throw with its bare message', async () => {
417+
for (const site of SITES.filter((s) => !s.dataDoor)) {
418+
const result = await callTool(site, sandboxAbort('Not enough annual leave remaining'));
419+
expect(result, site.tool).toEqual({
420+
content: [{ type: 'text', text: "hook 'demoleave_leave_request_guard' threw: Error: Not enough annual leave remaining" }],
421+
isError: true,
422+
});
423+
}
424+
});
425+
});
426+
257427
describe('the record resource: a coded refusal adds its code beside the unchanged sentence (#23509)', () => {
258428
const URI = 'objectstack://objects/task/records/t1';
259429

0 commit comments

Comments
 (0)