You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit f4b4c8a
Browse filesBrowse the repository at this point in the historyBrowse files
fix(mcp): run_action and resume_run answer a throw through the /actions door's one reader (#31315)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"}
-->
_Requested by **Leehom** · [project
thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XSaaQXQ6J3byqVhvgRU927w)_
domain:server#6 · os-project-manager · session_01FEkQ5pVLKxR5eAhTYetnwR
Fixes#31271
Clause-②: yes (widening)
Metadata-protocol: none
## 维护者速读
- 改了什么:`/api/v1/actions` 对动作抛错的回答,原来是 runtime 里 catch 块中的一串内联判断,现在抽成
`@objectstack/types` 的一个函数 `actionsDoorAnswerOf`(放在 `/data` 用的
`mapDataError` 旁边);`/actions` 改调它,MCP 的 `run_action` / `resume_run`
也改调它。
- 为什么改:MCP 这两个工具对沙箱动作体不带 code 的 `throw` 直接把带动作体名字的包装文本回给 agent,而
`/actions` 回 `400 HOOK_ABORTED` 加内层那句话;裸
Error、`SQLITE_BUSY`、沙箱崩溃也各不相同。现在两边读同一个函数,不会再分叉。
- 风险与回滚:`/actions` 的回答逐字节不变(runtime 全量 555 个测试文件全绿,现有 pins 一条未改);MCP
这两个工具的错误回答按上表变化;抛出的 `details` 只随拒绝(状态码低于 500)带出,故障(500 及以上)不再带。回滚即还原本
PR。
-
先看:`packages/types/src/actions-door-answer.ts`(级联本体,注释随迁),`packages/runtime/src/domains/actions.ts`
的 catch 尾部,`packages/mcp/src/mcp-http-tools.ts` 的
`errorResultFromThrown`。
- 两分钟验证:`pnpm --filter @objectstack/mcp exec vitest run
src/mcp-coded-refusal.test.ts -t 31271`,预期 17 条全绿。
- 需要你做的:无,席位落地(落地前要一次 TIER_DEFAULT 契约复审,因为 `@objectstack/types`
多了一个公开导出)。
## What changes
- **`@objectstack/types`: new `actionsDoorAnswerOf(err, objectName?)`
and its `ActionsDoorAnswer` type** (`src/actions-door-answer.ts`,
exported from the index beside `data-error-classification`). It is the
`/api/v1/actions` door's answer to a thrown value: the arms its catch
spelled inline, moved in the same order with the reasons and history
each one carried (the sandbox wrapper kept off the wire through
`sandboxBusinessMessage`; a sandboxed crash is the `500` fault; a
declared status is served through `resolveThrownHttpError` at a `500`
fallback; a driver NOT NULL refusal answers
`driverNotNullRefusalAnswer`'s arm; an unexpected fault is the sanitised
`500`; everything else is the `400` rejection). Arms 3 and 6 answer as
the dispatcher answers a thrown value: the closed code, the engine
unique class at its wire spelling with the REST arm's context, the
demoted `declaredCode`, a declared `userMessage`, and a leaky 5xx
sentence withheld. It returns the operator lines the door prints (the
full wrapper, the fault line).
- **`@objectstack/runtime` `domains/actions.ts`**: the catch keeps its
earned-answer arm (declarative update) and answers everything else
through `actionsDoorAnswerOf`, printing its operator lines with the same
`[action OBJECT/ACTION]` prefix and building the body with
`apiErrorResponse`, the builder the dispatcher's `error` /
`errorFromThrown` exits use. Refactor only: no answer moves.
- **`@objectstack/mcp` `errorResultFromThrown`** (`run_action`,
`resume_run`) answers through the same function: `{ error: { code,
message, status, declaredCode?, userMessage?, details? } }`. The
`PERMISSION_DENIED` withhold is kept. #15942's thrown `details` now ride
only on a refusal (status below 500), never on a fault, which answers
only the door's sanitised envelope. The context the door adds (`fields`,
`issues`, the unique arm's `object` / `field`) is not served, as on
every other MCP tool.
| `run_action` / `resume_run` caught | Before (main 7c523aa) | After (=
`/actions`) |
| --- | --- | --- |
| code-less sandboxed body abort | bare wrapper text naming the body |
`400 HOOK_ABORTED`, the inner sentence |
| bare `Error('socket hang up')` | bare text, no code | `400
VALIDATION_ERROR`, `socket hang up` |
| `SQLITE_BUSY` driver error | `{ code: SQLITE_BUSY, message }`, no
status | `400 VALIDATION_ERROR`, `declaredCode: SQLITE_BUSY` |
| sandboxed crash, no code | bare wrapper with `TypeError: boom` | `500
INTERNAL_ERROR`, `Internal server error` |
| sandboxed crash declaring `RECORD_LOCKED` / 409 | same as above | same
`500` fault |
| app's own unregistered code `LEAVE_EXHAUSTED` / 409 | `code:
LEAVE_EXHAUSTED`, 409 | `409 RESOURCE_CONFLICT`, `declaredCode:
LEAVE_EXHAUSTED` |
| a fault (503, or a sandboxed crash) carrying thrown `details` | bare
text, no `details` | no `details`, the door's envelope only |
| CONTROL: coded sandboxed refusal `RECORD_LOCKED` / 409 |
`RECORD_LOCKED`, inner sentence, 409 | unchanged |
| CONTROL: `ACTION_CONFIRMATION_REQUIRED` / 428 with `details` | code,
428, `details` | unchanged |
## Tests
- New `packages/types/src/actions-door-answer.test.ts`: 10 rows, one per
arm plus controls (abort, bare Error, driver string code, sandboxed
crash with and without a code, in-process crash, coded refusal, declared
status with an unregistered code, leaky declared 5xx, `userMessage`, NOT
NULL arm).
- `packages/mcp/src/mcp-coded-refusal.test.ts`: new #31271 block, 17
rows (the 5 cases on each tool, each asserting the literal answer AND
equality with `actionsDoorAnswerOf` on a fresh copy of the throw, no
body name, no `TypeError`, the operator log count; the unregistered
`LEAVE_EXHAUSTED` / 409 row on each tool; the fault-with-`details` row
on each tool, which also keeps a 428 refusal's `details`; the CONTROL on
each tool). Two existing rows move with the behaviour: the #23509
code-less row for the two action tools now pins the door's `400`
rejection, and the #31218 bare-message CONTROL excludes them.
`mcp-action-confirmation-member.test.ts`: the uncoded-failure row now
pins the door's answer.
- Runtime `/actions` pins: unedited, all green.
- Fallout,
`packages/cli/test/package-install-local-handlers.integration.test.ts`:
the ghost-action `run_action` pin read the old bare text with
`String(payload)`. It now reads the envelope: `VALIDATION_ERROR`, `400`,
and a message that still says `No handler registered`.
## Acceptance notes
- **The `/actions`-vs-`/data` difference on a driver string code
stays.** `/actions` answers `SQLITE_BUSY` `400 VALIDATION_ERROR` with
`declaredCode`; `/api/v1/data` answers `500 DATABASE_ERROR`. These tools
now answer what `/actions` answers, per the seat ruling; which REST door
is right is not decided here.
- **One composition left beside the reader.** The dispatcher's
`errorFromThrown` / `error` (`http-dispatcher.ts`, outside this claim's
surface) still compose the same `@objectstack/types` pieces for every
other dispatcher domain. The `/actions` door no longer goes through
them; its bodies are held equal by its existing pins (runtime suite
green unedited).
- **`details` on the action tools.** Thrown `details` only, and only on
a refusal (status below 500); a fault serves the door's sanitised
envelope without them. The door's added context is not served on MCP, as
`errorResultWithCode` explains for every tool.
- **A ghost action (declared, no handler).** MCP `run_action` now
answers it `400 VALIDATION_ERROR` with `No handler registered…`, because
the bridge's `invokeBusinessAction` throws an uncoded error. `POST
/api/v1/actions` answers the same action `404` from its routing-miss
branch before any handler runs. That producer-side difference is outside
this PR.
- **Earned answers.** The declarative-update earned-answer arm stays at
the door (it needs the action definition); MCP's
`landedWriteResultOrError` already reads it before this renderer.
- **Superseded changeset line.** The pending `31218` changeset says
`run_action` and `resume_run` are unchanged by that card; this PR's
changeset supersedes that sentence and does not edit it.
## Verification
All on head `7d083983` (base `7c523aa1`); the first round ran on
`73bb0469`.
<details><summary>Red first, fix, suites</summary>
- Premise on `7c523aa1`, over `/mcp` (scratch probe, not committed):
`run_action` and `resume_run` answered `"action 'demo_body' threw:
Error: Not enough leave"`, `"socket hang up"`, `{ code: SQLITE_BUSY,
message }` with no status, and the wrapper with `TypeError: boom` for a
crash with or without a code; the coded refusal CONTROL answered
`RECORD_LOCKED` / `Locked.` / 409.
- Red first: the new pins on the base MCP source,
`mcp-coded-refusal.test.ts`: Tests 12 failed | 94 passed (106) (10 new
rows, the 2 moved code-less rows; the CONTROLs green). With the fix: 106
passed.
- `pnpm --filter @objectstack/mcp test` on `7d083983`: Test Files 60
passed (60), Tests 839 passed (839).
- `packages/cli/test/package-install-local-handlers.integration.test.ts`
on `7d083983`: Test Files 1 passed (1), Tests 17 passed (17).
- `pnpm --filter @objectstack/types test`: Test Files 37 passed (37),
Tests 870 passed (870).
- `pnpm --filter @objectstack/runtime test` (types aliased to source;
runtime source identical since `8b4876fb`): Test Files 555 passed (555),
Tests 8939 passed, 19 skipped; lock VERDICT command-exit 0. No
`/actions` pin edited.
- `typecheck` for `@objectstack/types`, `@objectstack/runtime`,
`@objectstack/mcp`: exit 0 (runtime test layer 27 files / 186 errors /
66 pinned signatures held; mcp 6 / 53 / 8 held).
- `eslint --no-inline-config` on all 8 touched `.ts` files across two
runs (the 7 of the first run, then the 3 this hop touched): exit 0.
</details>
<details><summary>Ablations (ablation-replace, restored to HEAD each
time)</summary>
- `mcp-http-tools.ts`: the PERMISSION_DENIED line rewritten to answer
`errorResult(messageOf(err))` for every throw (bypassing the reader's
answer). Mutation landed (anchor 1 to 0, blob 33371b9 to
2b20f1be220e); `mcp-coded-refusal.test.ts` +
`mcp-action-confirmation-member.test.ts`: Tests 21 failed | 91 passed
(112). Restored: blob == HEAD, `git diff HEAD` empty.
- `actions-door-answer.ts`: the sandboxed-crash arm disabled (a
condition that is never true, `String("ABL").length` less than 0). Types
`actions-door-answer.test.ts`: 1 failed | 9 passed (the crash row);
runtime `actions-undeclared-hook-abort-code.test.ts` +
`nested-hook-refusal-is-a-rejection.test.ts` (types aliased to source):
1 failed | 11 passed, the `/actions` crash pin, which shows the door
answers through the reader. Restored: blob == HEAD, `git diff HEAD`
empty. Both suites import source; no dist involved.
- Details guard (`mcp-http-tools.ts`): the `status < 500` condition made
always true (`|| String("ABL").length > 0`), so thrown `details` ride on
every answer. Mutation landed (anchor 1 to 0, blob 91fd3c0 to
5d4314c7940f). `mcp-coded-refusal.test.ts -t 31271`: Tests 2 failed | 15
passed (the fault-with-`details` row on each tool). Restored: blob ==
HEAD.
</details>
<details><summary>Derived gates</summary>
`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` at `7d083983`: 67 commands (the cli test
file adds `check:cli-test-child-env`), each run with stdin from
`/dev/null`, all exit 0 on the first run. `dispatch-gates --ran`: 67
derived families accounted for, 67 run, 0 NOT-MEASURED. The cli
integration suite is not a derived family; the one edited file was run
on its own (above). Not run locally (CI): `test:repo`, dogfood shards,
type-check lanes.
</details>
---
_Generated by [Claude
Code](https://claude.ai/code/session_01FEkQ5pVLKxR5eAhTYetnwR)_
<!-- signed: domain:server#6 · os-project-manager ·
session_01FEkQ5p3T5YWhLjurajudnQ -->
---------
Co-authored-by: Claude <noreply@anthropic.com>
fix(mcp): `run_action` and `resume_run` answer a thrown error the way `POST /api/v1/actions` does, through that door's one reader
8
+
9
+
Clause-②: yes (widening)
10
+
Metadata-protocol: none
11
+
12
+
MCP `run_action` and `resume_run` answered an error thrown while running an action differently from `POST /api/v1/actions/:object/:action`, which runs the same action:
13
+
14
+
| The action threw | MCP answered |`/actions` answers, and MCP now answers |
15
+
| --- | --- | --- |
16
+
| A sandboxed body's own `throw new Error('Not enough leave')`| the runner's wrapper as bare text, naming the body: `action 'NAME' threw: Error: Not enough leave`|`400`, `HOOK_ABORTED`, `Not enough leave`|
17
+
| A plain `Error`| its bare text, no code |`400`, `VALIDATION_ERROR`, its own sentence |
18
+
| A driver error with a string code (`SQLITE_BUSY`) |`{ code: 'SQLITE_BUSY', message }`, no status |`400`, `VALIDATION_ERROR`, `declaredCode: 'SQLITE_BUSY'`|
19
+
| A sandboxed body that crashed (`TypeError`), with or without a declared code | the wrapper with the native error text |`500`, `INTERNAL_ERROR`, `Internal server error`|
20
+
21
+
The tool error is `{ "error": { "code", "message", "status" } }` with `isError`, plus `declaredCode` and `userMessage` when the door serves them. The full wrapper and the fault line are logged on the server, as the door logs them. A refusal with a registered code (a body that throws `{ code: 'RECORD_LOCKED', status: 409 }`) keeps its code, its own sentence and its status. A refusal with an app's own unregistered code (`{ code: 'LEAVE_EXHAUSTED', status: 409 }`) now crosses as `/actions` serves it: the closed code its status derives (`RESOURCE_CONFLICT`), with `declaredCode: 'LEAVE_EXHAUSTED'` beside it; MCP used to answer `code: 'LEAVE_EXHAUSTED'`. A thrown `details` (the confirmation gate's) still rides on a refusal and is no longer served on a fault (status 500 and above), which answers only the door's sanitised envelope; the `PERMISSION_DENIED` withhold is unchanged. This supersedes the line in the `31218` changeset that says `run_action` and `resume_run` are unchanged.
22
+
23
+
A driver string code answers what `/actions` answers. `/api/v1/data` answers the same throw `500 DATABASE_ERROR`; that difference between the two REST doors is not changed here.
24
+
25
+
**New export (`@objectstack/types`): `actionsDoorAnswerOf(err, objectName?)` and its `ActionsDoorAnswer` type**, the `/api/v1/actions` door's answer to a thrown value, beside `mapDataError` (the `/api/v1/data` door's). The `/actions` door (`@objectstack/runtime`) now answers through it; its responses are unchanged byte for byte.
0 commit comments