Skip to content

feat(runtime, rest, plugin-auth): an OAuth agent's api-action forward carries a server-minted forward credential (ADR-0090 D10 rule 5) - #28482

Merged
os-zhuang merged 4 commits into
mainfrom
claude/project-thread-hem8y5
Nov 1, 2026
Merged

os-zhuang merged 4 commits into
mainfrom
claude/project-thread-hem8y5

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Tesla · project thread

Fixes #28367

Clause-②: yes (widening, at the two named cells only)
Metadata-protocol: none

This is step 2 of the ruling's order, the code half of the ADR-0090 D10 rule 5 amendment that landed in #28455 (a99de44). Ruling of record 6545950368 (go, as shaped). Ruling of record 6548923676 (batch #436 item 1, A): api_key × identity keeps refusing.

维护者速读

  • 改了什么:OAuth 连接的 AI agent 运行 api 动作时,进程内转发不再被点名拒绝,而是带一张服务端为这次转发单独签发的转发凭据。凭据绑定这一次的方法、路由和动作运行,第一次出示即作废,目标应答即撤销,30 秒上限,只在进程内本次调用链里能兑换。目标端把它解析成同一个 agent(scope 上限、代表的用户、客户端),身份路由处 better-auth 只看到委托用户的请求级视图,不建任何会话。
  • 为什么改:ADR-0011 已把「改我的资料」和需确认的管理员身份动作开放给 agent,但今天 agent 一个都跑不通(runtime(mcp): an OAuth-token agent's api action forward carries a bearer the target door does not accept (acceptOAuthAccessToken matches /mcp paths only), so the target likely resolves the caller as anonymous #28178 表把这两格点名拒绝)。Tesla 已裁决按 ADR 修正案实现(记录 6545950368),第一步 ADR 已合入(docs(adr): ADR-0090 D10 rule 5, the agent forward credential (step 1 of #28367) #28455)。
  • 风险与回滚:只翻两格,OAuth agent × 身份路由、OAuth agent × 平台路由。后者要求 actions:execute + data:write,元数据门的写操作仍按名拒绝(403)。API key × 身份路由按裁决 6548923676 继续拒绝。目标端签出的会话 cookie 或轮换 token 不会回到 agent。回滚即 revert 本 PR,无数据迁移。
  • 先看:packages/rest/src/forward-credential.ts(凭据四条性质),packages/runtime/src/action-execution.ts 的 forwardApiAction 和 scope 族规则,packages/plugins/plugin-auth/src/agent-forward-identity-view.ts(身份路由的请求级视图)。
  • 两分钟验证:看 CI 的 dogfood 分片,mcp-oauth-api-action-forward.dogfood.test.ts 里真实 OAuth agent 改自己资料成功、审计行同时记 performed_by 和 on_behalf_of、只读 agent 和元数据写被按名拒绝。
  • 需要你做的:以 os-zhuang 身份审批(Tier H,改了 docs/adr/** 的两句话,席位会发起评审请求)。下方 Acceptance notes 里有两个开放问题请裁决。

What changes

  • packages/rest/src/forward-credential.ts (new). The one producer and redeemer of the credential.
    • withForwardCredential mints 256 random bits per forward, bound to the method, the route and the action run, and revokes it in finally when the forward answers.
    • redeemForwardCredential answers the agent's provenance only inside the forward's own async chain, spends it on the first presentation (accepted or refused), and refuses another method, another route or an expired one.
    • The handle names nothing; nothing is stored, logged or returned.
  • packages/runtime/src/action-execution.ts.
    • The two cells flip. oauth × identity and oauth × platform now forward as the agent. api_key × identity is unchanged and still refuses by name.
    • The scope-family rule for targets outside MCP (apiForwardScopeFamilyRefusal): actions:execute always, data:write for a state-changing method, and a state-changing metadata-door target refused. Each refuses 403 PERMISSION_DENIED by name, before the mint. list_actions marks the action from the same predicate.
    • For an agent the forward carries no caller identity header, only the credential, and relays no response header back.
  • packages/runtime/src/security/resolve-execution-context.ts and packages/rest/src/rest-server.ts. A redeemed credential is handed to the shared assembler as the oauth provenance, exactly as the MCP door hands a verified token's. The target builds the same agent principal (scope-derived permissions, onBehalfOf, performedBy, oauthScopes, the consent organization). Credential admission is not recorded for a forwarded request. An unredeemable credential is ignored.
  • packages/plugins/plugin-auth.
    • agent-forward-identity-view.ts (new) is a middleware on the auth base path plus a better-auth plugin. A redeemed credential becomes a request-scoped in-memory view of the delegating user: no sys_session row, no token, no cookie. A banned or missing user gets no view. Credential response headers are stripped for the agent caller.
    • auth-actor-attribution.ts and objectql-adapter.ts: identity writes made inside the view carry performedBy, so the audit row records performed_by and on_behalf_of.
    • auth-plugin.ts and auth-manager-plugin-list.ts: wiring.
  • docs/adr/0090-…md. The two sentences the amendment names are corrected: rule 4's "today the OAuth / MCP client door alone", and the 2026-09-16 note's "REST and stdio hand the assembler oauth: undefined by construction".
  • content/docs/ai/actions-as-tools.mdx. The api row states the new table.
  • Tests.
  • Changeset. .changeset/28367-agent-forward-credential.md (patch for rest, runtime and plugin-auth).

Acceptance notes

  • Open question 1: a response body. The ADR bans relaying credential headers to an agent, and this PR strips them. It says nothing about a target that returns a credential in its response body, and this PR does not inspect bodies. Recommendation: a follow-up names it in the ADR and refuses agent forwards to any route known to answer with a credential.
  • Open question 2: key minting through the data door. An agent forward to the API-key object's data route is not refused by apiKeyManagementCredentialRefusal, which refuses only the api_key credential. The agent's own scope ceiling and the user's permissions still apply. Recommendation: refuse agent principals there too, as a follow-up on the key-management door, since that door is outside this PR's surface.
  • The identity seam is a middleware, not handleRequest. The amendment names auth-manager-request-routing.ts#handleRequest. The platform's raw identity mounts (for example update-user) are served without passing through handleRequest, so a seam there would miss them. The middleware runs ahead of every route under the auth base path, after the IP gate.
  • The view's session is fresh. Its createdAt is the request time, so a route that requires a fresh session accepts the agent's view. The user's own consent and better-auth's own checks still stand. Flagged for the contract review.
  • The safe-method branch is unreachable today. The action schema allows only POST, PATCH, PUT and DELETE, so every agent forward outside MCP needs data:write. The data:read branch is implemented as the rule states and unit-pinned.
  • The forward has no request timeout. The credential's 30-second bound covers a hanging target. The target's own timeout is unchanged.
  • The REST action door is unreachable for an agent. An OAuth access token is honoured on the MCP endpoint alone, so the live measure and the dogfood pins run through MCP run_action. The REST action door shares the table function and is pinned at unit level.
  • Out of the claim's file surface: scripts/engine-double-contract.pinned.json (regenerated with --write for the new test doubles), five scripts/adr-anchors/*.json entries for the new and changed files, packages/rest/src/index.ts (exports), and packages/runtime/src/domains/actions.ts (passes recordId into the forward binding). ⛔ No packages/spec, no metadata-protocol/src/protocol.ts.
  • Left untouched on purpose: the 2026-09-16 note's "produced at exactly one seam" wording. It is not one of the two named sentences.
  • Live measure on main 52306d4 before the code: both flipped cells refused by name, api_key × identity refused, api_key × platform served, and an outside OAuth token refused 401 at the data door and the identity mount (2 dogfood files, 16 of 16 pass). Kept at rule level, since this is a security card.
  • Diff size: 2027 changed lines (+1898 / -129) across 28 files, under the 3000 threshold.

Verification

Local, on 6bbf2eeb: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 110 families for the changed paths. Each ran with stdin from /dev/null, and its exit code was captured by redirect before any pipe.

Gate readings
  • First pass: 108 of 116 lines exited 0. The reds and their fixes:
    • check-plugin-teardown-shape --self-test: the pinned commit was missing from the shallow clone; after git fetch origin 621a4876… --depth=1 it exits 0.
    • check:engine-double-contract: the new test doubles; ledger regenerated with --write and committed.
    • check:objectql-double-limit: both new doubles now honour limit.
    • check:where-matcher: the new matcher refuses $ combinators and operators.
    • check:test-source-alias: a plugin-auth vitest alias was reverted; plugin-auth reads @objectstack/rest as its other tests do.
    • rest test typecheck (TS6133): an unused binding was removed.
  • Second pass, all 110 derived families: exit 0.
  • dispatch-gates --ran: 110 derived famil(ies) accounted for — 110 run, 0 NOT-MEASURED, exit 0.
  • Typecheck: pnpm --filter <pkg> run typecheck for @objectstack/rest, @objectstack/runtime, @objectstack/plugin-auth and @objectstack/dogfood, each exit 0.
  • pnpm lint, scoped to packages/rest, packages/runtime, packages/plugins/plugin-auth and packages/qa/dogfood/test: exit 0.
  • Sibling reader suites (outside the derivation):
    • @objectstack/rest: 6884 passed.
    • @objectstack/mcp: 603 passed.
    • @objectstack/runtime: 8055 passed.
    • @objectstack/plugin-auth: first run 2 failed (the two plugin-list pins in auth-manager.test.ts, which now name the new view plugin); after the fix, 3938 passed.
  • Dogfood: the two files, 22 of 22 passed.
Ablations (each pin red with the forbidden behaviour put back, then restored byte-identical; `git status` clean after each)

Unit, via scripts/ablation-replace.mjs:

Ablated Red
replay: first presentation does not spend 2 failed
revoke on answer removed 1 failed
expiry check removed 1 failed
method binding removed 3 failed
route binding removed 2 failed
out-of-process: a slot readable outside the chain 1 failed
api_key × identity accepted 5 failed
metadata-door refusal removed 1 failed
data:write requirement removed 3 failed
actions:execute requirement removed 1 failed
caller identity headers carried for an agent 2 failed
response headers relayed to an agent 2 failed
runtime: admission recorded for a forward 1 failed
REST: admission recorded for a forward 1 failed
view served for a banned user 1 failed
credential response headers not stripped 1 failed
caller identity headers not dropped at the mount 1 failed
an unredeemed credential header passed through 1 failed

Dogfood, via scripts/ablation-dist-preflight.mjs on the built dist:

Ablated Red
metadata-door refusal removed 2 failed
data:write requirement removed 1 failed
api_key × identity accepted 1 failed
the view answers the user as an admin 2 failed (both in the exposure file)

Generated by Claude Code

… carries a server-minted forward credential (ADR-0090 D10 rule 5)

The two cells the #28367 amendment flips, and nothing else: an agent
connected over OAuth, forwarded to an identity route or to another
same-origin route, now reaches the target as the same agent principal
instead of being refused by name. The platform cell is bounded by the
scope-family rule. api_key x identity keeps refusing by name.

- @objectstack/rest forward-credential: mint, bind (method, route, run),
  spend on first presentation, revoke when the forward answers, expire,
  and redeem only inside the forward's own async chain.
- runtime action door: the table's two flipped cells, the scope-family
  predicate shared by run_action, the REST action door and list_actions,
  no caller identity header on an agent's forward, no rotated credential
  relayed back.
- runtime dispatcher and REST face: a redeemed credential is the agent's
  provenance for the shared assembler; no admission is reported for it.
- plugin-auth identity mount: a request-scoped, in-memory view of the
  delegating user for better-auth, no session row, the agent's client
  carried as performedBy on identity writes.
- ADR-0090: the two sentences the amendment names are corrected.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibyagSyLB5ckJsKghoa
…gine-double contract); plugin-auth reads @objectstack/rest as its other tests do

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibyagSyLB5ckJsKghoa
…registered after bearer() on every instance

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibyagSyLB5ckJsKghoa
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Oct 31, 2026
@github-actions

github-actions Bot commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/plugin-auth, @objectstack/rest, @objectstack/runtime, touching 51 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/rest/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

61 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json ee29ac39e840de60bc0c9812d6113aac321c51a0.

⛔ 14 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/rest/src/index.ts) — pages documenting those are invisible to this run
  • 1 cross-cutting symbol(s) contributed no route anchor: userId (4 routes)
  • 1 anchor(s) matched too much of the corpus to be a work list: /api/v1/meta (route, 45 pages)
  • 7 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 212 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 50 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json ee29ac39e840de60bc0c9812d6113aac321c51a0 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from ad374db74fb378f3e556577e27513ade6f5d4d5a — the merge of head 684ebbfa9c65c491842c91a053a22625883803cc into base ee29ac39e840de60bc0c9812d6113aac321c51a0, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin ad374db74fb378f3e556577e27513ade6f5d4d5a && git checkout ad374db74fb378f3e556577e27513ade6f5d4d5a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ee29ac39e840de60bc0c9812d6113aac321c51a0 684ebbfa9c65c491842c91a053a22625883803cc && git checkout -B drift-repro ee29ac39e840de60bc0c9812d6113aac321c51a0 && git merge --no-ff 684ebbfa9c65c491842c91a053a22625883803cc

node scripts/docs-audit/affected-docs.mjs --json ee29ac39e840de60bc0c9812d6113aac321c51a0

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs ee29ac39e840de60bc0c9812d6113aac321c51a0 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…the wildcard fall-through guard can verify it yields

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibyagSyLB5ckJsKghoa
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

triage#1 · os-tesla · session_01L9qibw58ehQ2gRHP2h2Puz

Contract-review-claim: session_01L9qibw58ehQ2gRHP2h2Puz · head 684ebbfa9c65c491842c91a053a22625883803cc · 2026-10-31T23:13Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

triage#1 · os-tesla · session_01L9qibw58ehQ2gRHP2h2Puz

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 684ebbfa9c65c491842c91a053a22625883803cc
Local-runs: none
Scope: PR #28482 (Fixes #28367 step 2, the code half of ADR-0090 D10 rule 5; go ruling 6545950368, api_key × identity ruling A 6548923676; claim 6546502251 + amendment 6548876208; dev reports 6550281464 and the head-move note 6550351766; claim 6550439107), 28 files, +1902/−129 against merge-base 9b1dc992, merge-tree clean (one ledger overlap, disjoint rows). Tier H: docs/adr/0090-permission-model-v2-concept-convergence.md carries the two sentence corrections the amendment names; os-zhuang approves. The 6bbf2eeb → 684ebbfa move is one file (auth-plugin.ts): the seam's handler inlined at the use() call for check:wildcard-fallthrough, behaviour unchanged.
Inputs: card #28367 (shape, triage 6540849042, both rulings, the step-1 landing record), the amendment text on origin/main (properties 1–4, the cell table, the scope-family rule, the owed pin list, the two sentences), the PR body (速读, 18 unit + 4 dogfood ablations, live measure before the code), the full diff by file, origin/main for the seams the code reuses (resolve-execution-context.ts L186–216 session: oauthPrincipal ? { established … } and the oauth assembler input at L255–275; core request-execution-context.ts L48 { established } contract; engine-execution-plumbing.ts L97 reads performedBy.clientId into the audit row; API_FORWARD_RELAYED_RESPONSE_HEADERS L1191), check-runs (read 2026-10-31T23:33Z: all 50 latest-per-name check-runs completed, 38 success + 12 skipped, none failed, none running) — 50 checks

① Ruling → code

  • Property 1 (server-minted, after the gates, no caller identity header): withForwardCredential is the one producer (256 random bits, names nothing); forwardApiAction calls it only after apiForwardCredentialRefusal returned null, i.e. after the action door's exposure / permission / confirmation / activation gates and the scope-family rule; for an agent the allow-listed authorization, cookie, x-admin-session-token, x-api-key are dropped (API_FORWARD_IDENTITY_HEADERS), so the agent's token never reaches the target.
  • Property 2 (bound to one forward): the slot carries upper-cased method, the path, { objectName, actionName, recordId } (both doors now pass recordId), and the agent; redeemForwardCredential compares method and the decoded pathname (query excluded), refusing any other.
  • Property 3 (single-use, short-lived, non-replayable): spent on first presentation after the handle matched, accepted or refused; finally marks it spent when the forward settles; FORWARD_CREDENTIAL_TTL_MS 30 s pinned ("expires after the fixed bound even while its forward is still running"). The amendment's "no longer than the forward's own request timeout": the forward has none (body note), so the 30 s bound is the pin; read as satisfying the clause, flagged in ④.
  • Property 4 (unreachable out of process): an AsyncLocalStorage slot on a Symbol.for global (dual-load safe), the header carries a handle compared timingSafeEqual against the slot's; another async chain has no slot, so a network presentation redeems to nothing (pinned at unit level in three packages and in dogfood). Nothing stored, logged, answered or audited; presentedForwardCredential reads only.
  • Same agent principal at the target: the dispatcher sets oauthPrincipal from the redeemed provenance and falls into the existing session: { established … } + oauth: { … scopePermissions, delegatesActions } assembler input unchanged (L186–275 on main), so principalKind: 'agent', scope-derived ceiling, onBehalfOf, performedBy, oauthScopes and the consent organization (re-vetted: "a consent organization the delegator no longer belongs to is dropped") are built by the one assembler; the REST face mirrors that exact shape and otherwise keeps oauth: undefined. onAdmission is withheld for a forwarded agent on both doors, so the key-management rule and the ADR-0069 gate never read it as a session. packages/core untouched, as the amendment requires.
  • No human session: the identity mount resolves the credential in a middleware ahead of every route under the base path (the vendor catch-all and the raw mounts alike, the body's stated deviation from handleRequest, which the raw mounts bypass), rebuilds the request with no identity header and a 192-bit opaque bearer, and a better-auth plugin answers only findOne({ model: 'session', where: [token eq view.token] }) from memory inside that chain, for an existing, non-banned user, with activeOrganizationId the consent's; every other read and every write passes through. No sys_session row, no cookie, no token outside the chain; credential response headers (set-cookie, set-auth-token, set-admin-session-token) stripped at the mount and, independently, nothing relayed by forwardApiAction for an agent. Dual attribution: agentForwardPerformedBy() rides authSystemWriteContext and the adapter's asAttributedSystem as performedBy, which the audit plumbing already records (performedByClientId).
  • The table: API_FORWARD_AGENT_CREDENTIAL_DOORS = { identity, platform }; the flip applies only when kind === 'oauth' and apiForwardAgentOf resolves an agent (principalKind: 'agent', oauthScopes, a delegator and a client), so an OAuth caller with no client stays refused by name; api_key × identity falls through to the unchanged 401 sentence (ruling A); oauth × mcp accepted on the caller's own token as before.
  • Scope-family rule: apiForwardScopeFamilyRefusal refuses 403 PERMISSION_DENIED by name before any mint: actions:execute always, data:read or data:write for GET/HEAD, data:write otherwise, and a state-changing method on /api/v1(/environments/:id)?/meta refused whatever the scopes; read from the one predicate by run_action, the REST action door and list_actions (pinned both ways). The method judged is action.method default POST, now passed from the built request so listing and run agree.
  • The two named sentences are the two corrected (rule 4's "today the OAuth / MCP client door alone"; the 2026-09-16 note's REST "by construction"), and the "produced at exactly one seam" wording is left, as the amendment allows. The docs api row restates the table.

② Declarations

③ Pins and ablations

  • The amendment's owed pin list is covered: each flipped cell positively by a real OAuth-connected agent in dogfood (data-door write and identity write served as the agent, audit row naming performed_by and on_behalf_of, admin ban through the admin route with no session left behind); every kept refusal by name (api_key × identity with its data-door action still served, metadata-door write whatever the scopes, read-only agent at identity and platform targets); the credential's properties (second presentation, other method/route, expiry, out-of-process, guessed handle, revoked after answer, handle names nothing); the intersection at the target (a plain member's confirmed agent refused by the identity route, nobody banned); no session (view pinned to answer nothing outside its chain, banned/missing user no view); the boundary unmoved (outside OAuth token refused on the REST face); anchors in scripts/adr-anchors/ for the five seams.
  • 18 unit ablations + 4 dogfood dist ablations, each red on the matching pin and restored; 110 derived families exit 0; sibling suites rest / mcp / runtime / plugin-auth green (two plugin-list pins updated for the new plugin). Live measure before the code on 52306d40 kept at rule level.

④ Traps and should-fix

  • Open question 1 (a target that answers a credential in its response body): the amendment bans headers only; this PR strips headers and does not inspect bodies. Agree with the seat's recommendation: a follow-up names it in the ADR and refuses agent forwards to any route known to answer a credential in its body. Not a block: no such same-origin route is an api action target today, and the agent's own scope ceiling applies.
  • Open question 2 (apiKeyManagementCredentialRefusal judges the api_key credential only, so an agent forward to the API-key object's data route is judged by the agent's ceiling and the user's permissions, not refused by name): follow-up on the key-management door, as the seat recommends; outside this PR's surface. Note the forwarded agent reports no admission, which is why that rule sees nothing.
  • Open question 3 (the view's session is fresh, so a fresh-session route accepts the agent's view): A, accept. The amendment makes better-auth's own authorization of the delegating user the second gate; freshness is a property of the session better-auth is shown, and the view is created for that one request only. A backdated view would refuse the very identity actions ADR-0011 exposes. If the maintainer wants fresh-session routes closed to agents, that is an ADR-0011 bucket decision, not this credential's.
  • Property 3's "no longer than the forward's own request timeout": the forward has no timeout, so the 30 s bound is the only bound; a later card that adds a forward timeout should keep FORWARD_CREDENTIAL_TTL_MS ≤ it.
  • The view plugin wraps better-auth's adapter with a Proxy whose only override is findOne; a better-auth upgrade that reads sessions through another adapter verb (e.g. findMany) would make the view answer nothing (fails closed, the identity actions would stop working for agents). The dogfood pins would red on an upgrade.
  • apiForwardAgentOf reads the consent organization from ec.tenantId, the vetted value the MCP door already resolved; the target re-vets it. Consistent, but a card that changes where the MCP door keeps the consent organization must update this reader.

Implemented-by: claude/project-thread-hem8y5
Reviewed-by: triage#1 · os-tesla · session_01L9qibw58ehQ2gRHP2h2Puz

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-tesla · session_01L9qibz3ZUbZ8Qcn1rDw9yc

维护者速读

  • 改了什么:OAuth 连接的 AI agent 运行 api 动作时,进程内转发不再按名拒绝,改为带一张服务端为这一次转发签发的凭据。凭据绑定这一次的方法、路由和动作运行,第一次出示即作废,30 秒上限,只能在本进程的这条调用链里兑换。目标端把它还原成同一个 agent。身份路由处 better-auth 只看到委托用户的一次性视图,不建会话。
  • 为什么改:你对 [Decision] auth: an agent forward credential — a server-constructed, target-bound, single-use credential so an OAuth- or API-key-connected agent can run an api action whose target is an identity route (ADR-0090 D10 amendment) #28367 的「按原样做」裁定(记录 6545950368),以及批次 Release v0.7.1 #436 第 1 项的裁定 A(记录 6548923676)。ADR 修正案已在 docs(adr): ADR-0090 D10 rule 5, the agent forward credential (step 1 of #28367) #28455 合并,这里是代码那一半。
  • 风险与回滚:只翻两格,即 OAuth agent × 身份路由、OAuth agent × 平台路由。平台路由要求 actions:execute 加 data:write,元数据门的写操作仍按名拒绝;API key × 身份路由仍拒绝。目标端签出的 cookie 或 token 不会回到 agent。回滚即 revert,无数据迁移。
  • 先看:packages/rest/src/forward-credential.ts、packages/runtime/src/action-execution.ts 的 forwardApiAction、packages/plugins/plugin-auth/src/agent-forward-identity-view.ts。
  • 两分钟验证:CI dogfood 分片里的 mcp-oauth-api-action-forward.dogfood.test.ts。真实 OAuth agent 改自己的资料成功,审计行同时记 performed_by 和 on_behalf_of;只读 agent 和元数据写被按名拒绝。
  • 需要你做的:Tier H(改了 ADR-0090 的两句话),需要 os-zhuang 或 hotlong 批准。PR 正文的两个开放问题(凭据出现在响应正文里、经数据门管理 API key)契约复审认为不阻塞,作为后续卡;server 车道已下班,留给下一位席位按需建卡。

席位意见:契约复审 PASS,翻的正好是修正案点名的两格,其余拒绝都有按名的测试钉住。可以批准。

席位核对

ACCEPT · seat domain:server#1 · head 684ebbfa · 2026-10-31T23:34Z

  1. Shape: Fixes #28367 (step 2, the code half). Draft, base main, 28 files, +1902/−129. Patch changesets for @objectstack/rest, @objectstack/runtime and @objectstack/plugin-auth. No packages/spec path and packages/core is untouched, as the amendment requires.
  2. Ruling: go 6545950368 as shaped, with api_key × identity A 6548923676.
    • Only the two cells the amendment names flip.
    • The scope-family rule refuses by name before any mint.
    • api_key × identity falls through to the unchanged 401 sentence.
  3. Contract review: lane PASS 6550628773 at this head.
    • It declares Clause-②: yes (widening, at the two named cells only) and Metadata-protocol: none.
    • The fresh-session question was judged A: accept, since an ADR-0011 bucket decision is not this credential's to make.
  4. Gates / check-runs:
    • Dev report 6550281464: 18 unit ablations and 4 dogfood dist ablations each went red and were restored, and 110 derived families exit 0.
    • Head move 6bbf2eeb → 684ebbfa (6550351766): one file. The seam's handler is now inlined for check:wildcard-fallthrough, with behaviour unchanged.
    • On head 684ebbfa, all 50 check-runs completed: 38 success, 12 skipped, 0 failed.
  5. Accepted deviations:
    • The identity view resolves in a middleware ahead of every route under the auth base path, not in handleRequest, because the raw mounts bypass handleRequest.
    • The 30 s TTL is the only bound, because the forward has no request timeout. The review's ④ notes that a future forward timeout must stay ≥ it.
    • Open questions 1 and 2 are follow-ups. They are not filed by this seat, because the lane is off shift.
  6. Landing: GOVERNED, Tier H (docs/adr/**, ADR-0090). 2031 changed lines, under the 3000-line human-merge threshold. The seat readies the PR, and the relay requests os-zhuang and hotlong. The seat arms no auto-merge. After an authorized approval, the maintainer enqueues it, and the seat posts the landing record on [Decision] auth: an agent forward credential — a server-constructed, target-bound, single-use credential so an OAuth- or API-key-connected agent can run an api action whose target is an identity route (ADR-0090 D10 amendment) #28367.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 31, 2026 23:35
@os-zhuang
os-zhuang added this pull request to the merge queue Nov 1, 2026
Merged via the queue into main with commit d3fdd46 Nov 1, 2026
54 checks passed
@os-zhuang
os-zhuang deleted the claude/project-thread-hem8y5 branch November 1, 2026 03:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

2 participants