Repository navigation
fix(runtime): refuse an OAuth agent's api-action forward by name from one credential × target-door table, and mark it in list_actions - #28442
Conversation
… one credential × target-door table, and mark it in list_actions
An agent connected to MCP over OAuth holds an access token the platform
honours on the MCP endpoint only, so an api action's forward reached every
other target as nobody and the agent got the target's anonymous 401. Ruled B
(6540286612): one table of credential kind × target door, read by run_action,
the REST action door and list_actions. A caller whose credential the target
cannot read is refused 401 UNAUTHENTICATED before the forward with a sentence
naming the action, its target and what would let it run; list_actions keeps
the action and marks it `unavailable: { code, reason }`. The API-key
identity-route refusal is one cell of the table, its sentence unchanged.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WV4d1MkJvadehWmrLUUT1
…uth-api-action-forward
📓 Docs Drift CheckThis PR changes 2 package(s): 14 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 35 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 620a4aa745f87479e7d6a3350e86b36bfeaf479a && git checkout 620a4aa745f87479e7d6a3350e86b36bfeaf479a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5517005d3a53600300f5cb8974ae20f178b63132 373031aae6e59bcd8f78cf07faf61f3c2cd63250 && git checkout -B drift-repro 5517005d3a53600300f5cb8974ae20f178b63132 && git merge --no-ff 373031aae6e59bcd8f78cf07faf61f3c2cd63250
node scripts/docs-audit/affected-docs.mjs --json 5517005d3a53600300f5cb8974ae20f178b63132
|
|
domain:spec#4 · marchtian · session_017ihL2fi9zfJhUTubyJYb6V 维护者速读(席位终稿 · head
|
|
triage#1 · os-tesla · session_01L9qibw58ehQ2gRHP2h2Puz Contract-review-claim: |
|
triage#1 · os-tesla · session_01L9qibw58ehQ2gRHP2h2Puz Contract reviewServed-tier: ① Derived judgments
② Rulings cited
③ Narrowing / widening
④ Behaviour / flags
Implemented-by: |
|
domain:spec#4 · marchtian · session_017ihL2fi9zfJhUTubyJYb6V Landing stroke · 2026-10-31T16:56Z · head
|
🤖 Generated with Claude Code
https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V
Fixes #28178
Clause-②: yes (widening)
Metadata-protocol: none
维护者速读
api类动作时,现在在转发之前就被明确拒绝,拒绝语句写清是哪个动作、转发到哪里、为什么不能跑、换什么方式能跑;list_actions照样列出这些动作,但标上unavailable: { code, reason },智能体能直接告诉用户原因。packages/runtime/src/action-execution.ts(API_FORWARD_DOOR_ACCEPTS表与apiForwardCredentialRefusal);packages/runtime/src/domains/mcp.ts(list_actions加标记);packages/mcp/src/mcp-http-tools.ts(新增可选成员unavailable与导出类型)。pnpm --filter @objectstack/runtime exec vitest run src/domains/api-action-forward.test.ts,预期全绿,其中[#28178]一组 7 个用例覆盖表格逐格、两扇门的拒绝和list_actions的标记。What changes
An agent connected to MCP over OAuth holds an access token the platform honours on the MCP endpoint only. A same-origin
type: 'api'action is forwarded with the caller's own credentials, so the forward reached every other target as nobody, and the agent got the target's anonymous401("Sign in first") although it had authenticated. Ruling B (6540286612) on this card: refuse by name before the forward, from one table, and move no boundary.API_FORWARD_DOOR_ACCEPTSinpackages/runtime/src/action-execution.ts): an identity route (/api/v1/auth/…) accepts a session only; the MCP endpoint a session, an API key or an OAuth token; every other same-origin target a session or an API key.apiForwardCredentialKindreads the admitted credential (oauthwhen the execution context carriesoauthScopes, else the door'sadmission.credential);apiForwardCredentialRefusalreads the table.forwardApiAction(shared by MCPrun_actionand RESTPOST /actions/:object/:action) answers401UNAUTHENTICATEDwith a sentence naming the action, its declared target and what would let it run. The target is never called.list_actionsmarks, it does not hide.buildMcpBridge.listActionsreads the same predicate and addsunavailable: { code, reason }with the same code and sentencerun_actionwould answer.McpActionSummarygains the optionalunavailablemember and@objectstack/mcpexports theMcpActionUnavailabletype; thelist_actionstool description says what the mark means.api_key×identity): its sentence is unchanged, and the listing marks it for an API-key caller too.acceptOAuthAccessTokenis not touched), which credentials any target door accepts, and every forward the table allows.content/docs/ai/actions-as-tools.mdx(thelist_actionsrow and theapirow). Changeset:@objectstack/runtimeand@objectstack/mcp,patch(Metadata-protocol: none).One-PR exception. The card was relabelled to
domain:specon triage's retriage answer because the MCP listing's exported shape widens; thepackages/mcphalf (index.ts,mcp-http-tools.ts) and the runtime table ride this one PR under that answer's declared file surface. Nopackages/specfile is touched.Tests
packages/runtime/src/domains/api-action-forward.test.ts, new[#28178]block: the table cell by cell; MCPrun_actionfor an OAuth agent on a data-door-style target and on an identity-route target (named401UNAUTHENTICATED, nothing forwarded); the REST door's shared forward refusing the same caller; session and API-key callers of the same action still forwarded;list_actionsmarking every api action for the OAuth agent, nothing for a session, the identity-route action only for an API key. The[#28217]block passes unchanged on the moved cell.packages/qa/dogfood/test/mcp-oauth-api-action-forward.dogfood.test.ts: over a real OAuth access token on the booted showcase, the listing marks,run_actionanswers the named refusal and leaves the row untouched, the identity-route Ban action bans nobody, session and API-key lanes unchanged, and the boundary pin: the same token sent straight to the data door or an identity route is still refused.Acceptance notes
Verification
All readings on head
373031aae6(the branch after mergingorigin/maina8d2fd7f30, a clean merge; the branch delta stayed the same 9 files, +528 / -38).Targeted suites and typecheck
pnpm --filter @objectstack/runtime exec vitest run src/domains/api-action-forward.test.ts: Test Files 1 passed, Tests 49 passed.pnpm --filter @objectstack/runtime test(the merge brought in runtime commits, so the whole package was rerun): Test Files 493 passed, Tests 8031 passed, 19 skipped.pnpm --filter @objectstack/mcp test: Test Files 47 passed, Tests 603 passed.pnpm --filter @objectstack/runtime typecheck,pnpm --filter @objectstack/mcp typecheck: exit 0.pnpm --filter @objectstack/dogfood exec vitest run test/mcp-oauth-api-action-forward.dogfood.test.ts: Test Files 1 passed, Tests 5 passed.Gate families
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 91 families derived for the 9 paths; every one run, exit codes captured before any pipe.check:skill-examplesandcheck:dual-build-cjs-loads(packages outside the dogfood build closure had nodist/) andcheck:type-check-debt(its closure build did not get a turn on the shared lock). After building those packages, and runningcheck:type-check-debtunder the lock, all three exited 0.--ranreconciliation: 91 derived, 91 run, 0 NOT-MEASURED, 0 UNRUN (exit 0).pnpm lint: exit 0.