Skip to content

fix(runtime)!: connector connection door refuses delegated or agent callers on begin and revoke (ADR-0143 D5, ruling A) - #28474

Merged
objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/project-thread-0wkobq
Oct 31, 2026
Merged

objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/project-thread-0wkobq

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Tesla · project thread

Fixes #28468

Clause-②: no (narrowing)
Metadata-protocol: none

维护者速读

  • 改了什么:连接器连接门的「发起连接(含重连)」与「撤销」两步,现在按调用方类型点名拒绝代理调用方(代表某人行事的 AI agent、OAuth 访问令牌、API key),返回 403 PERMISSION_DENIED,文案说明规则:连接与撤销是人类管理员本人从登录会话里做的事。
  • 为什么改:Tesla 对 batch [WIP] Update all package versions to 0.7 #435 答「同意」,即裁决 A(security: grant manage_connector_connections to organization_admin (and its no-bypass variant) by default, before ADR-0143 S4's Console Connect (ruling B on #28327) #28427 评论 6548764795):连接一个组织的 OAuth 厂商账户决定该组织之后每次运行用谁的授权,ADR-0143 D5 把连接绑定到发起它的会话,只能由人类管理员亲自完成。
  • 风险与回滚:这是对代理调用方的收紧(BREAKING,changeset 已标)。人类管理员不受影响:平台管理员、组织管理员权限集、显式授予 manage_connector_connections 的人照旧;isSystem 照旧放行;读取连接状态与 OAuth 回调两条腿不变。回滚即 revert 本 PR。
  • 先看:packages/runtime/src/domains/automation.ts 的 refuseUngrantedConnectorConnectionAccess(新增按方法的拒绝分支)与 connectorConnectionDelegatedCaller;automation-connector-connection-door.test.ts 末尾新增的 describe 块。
  • 两分钟验证:pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2 src/domains/automation-connector-connection-door.test.ts,预期 31 条全绿,其中三类代理调用方在发起与撤销上各得 403。
  • 需要你做的:无,席位落地。

What changes

The rule (ADR-0143 D5, ruling A, record 6548764795). The connector connection door's begin leg (POST /api/v1/automation/connectors/:name/connection, connect and reconnect) and its revoke leg (DELETE on the same path) refuse a delegated or agent caller by name, whatever it holds. Connecting and revoking an organization's connector is a human administrator's act, taken from a signed-in session.

Which callers. connectorConnectionDelegatedCaller names three kinds:

  • agent: an envelope that names a delegator (principalKind: 'agent' or onBehalfOf), as the shared assembler builds it for an OAuth client acting for a user;
  • oauth: a caller admitted by an OAuth access token (the envelope carries oauthScopes);
  • api_key: a caller admitted by an API key (HttpProtocolContext.admission.credential).

The credential half is read through apiForwardCredentialKind (packages/runtime/src/action-execution.ts), the api-action forward table's own reading, so the two doors cannot disagree about which credential admitted a caller. A caller with no recorded credential is not named a delegate; the capability gate still judges it, fail-closed.

The refusal. 403 PERMISSION_DENIED, the door's existing status and code, with a sentence from connectorConnectionDelegatedCallerRefusal(kind) that names the caller's kind and the rule. It runs after the isSystem bypass and before the capability read, and ahead of the service probe and the query checks, like the gate it sits in.

Unchanged. Who holds manage_connector_connections (platform administrators, the organization-administrator sets, explicit grants). The system context's bypass. The read leg (GET …/:name/connection) and the OAuth callback (GET …/connectors/oauth/callback), for every caller.

Docs. content/docs/automation/connectors.mdx, the connect paragraph: connecting and revoking is a human administrator's act from a signed-in session, and an agent or an API key is refused by name.

Tests

New describe block in packages/runtime/src/domains/automation-connector-connection-door.test.ts. The envelopes are built by the real assembleExecutionContext from @objectstack/core, for an organization administrator holding the capability:

  • the three delegated fixtures are what they say (connectorConnectionDelegatedCaller names each, and names nothing for the signed-in holder);
  • agent, oauth, api_key: refused on begin and on revoke, 403 PERMISSION_DENIED, the message equal to connectorConnectionDelegatedCallerRefusal(kind) and naming the kind, no service member called;
  • the same human, signed in: admitted on begin and on revoke, the service handed tenantId: 'org_a';
  • a system call: admitted on begin and on revoke, even carrying an API-key admission;
  • agent, oauth, api_key: the read leg and the callback still answer 200 for a holder.

File: Test Files 1 passed (1), Tests 31 passed (31). Whole package on 1788eefbb: runtime Test Files 494 passed (494), Tests 8048 passed | 19 skipped (8067); pnpm --filter @objectstack/runtime typecheck exit 0.

Ablations (one per refusal branch, plus the whole rule)

Each mutation went through scripts/ablation-replace.mjs on packages/runtime/src/domains/automation.ts: the anchor landed on disk (count 1 → 0), the file was restored with the blob equal to HEAD and git diff HEAD empty. The suite imports its subject from src, so no dist was involved.

mutation red
drop the agent branch (principalKind === 'agent' || onBehalfOf) 2 / 31: the fixture pin (expected 'oauth' to be 'agent') and the agent refusal (the sentence names an OAuth token, not an agent)
the oauth credential no longer named 2 / 31: the fixture pin and the oauth refusal
the api_key credential no longer named 2 / 31: the fixture pin and the api_key refusal
the refusal never fires (if (false && delegated)) 3 / 31: the three refusal cases

The first attempt at the oauth mutation was refused by the tool itself (its replacement text was a substring of the original, so the count could not rise); it was re-run with a distinct replacement, and only that run is reported.

Acceptance notes

  • The agent branch reds on the name, not the status. The OAuth agent envelope also carries oauthScopes, so with the agent branch removed it is still refused, named as an OAuth token. The branch is what names an agent envelope that arrives without oauthScopes.
  • No ADR line owed. D5 already says a connection is "bound to the initiating session"; this PR enforces that sentence and edits no ADR.
  • content/docs/permissions/system-context.mdx row 57c (the door's isSystem bypass) still holds: the bypass reads once in the same function, and the census count is unchanged.
  • Branch claude/project-thread-0wkobq is the card thread's platform-assigned branch; claim 6548795089 names the card thread's branch.

Verification

Local, on 1788eefbb: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 91 families for the 4 changed paths. Each was run with stdin from /dev/null, its exit captured by redirect before any pipe: 91 exit 0. check-plugin-teardown-shape.mjs --self-test first exited 3 (its pinned fixture commit was not in the shallow clone); after fetching that commit it exited 0. --ran: 91 derived famil(ies) accounted for — 91 run, 0 NOT-MEASURED, exit 0. Scoped lint, the root lint script's eslint over packages/runtime with --no-inline-config: exit 0. pnpm --filter @objectstack/runtime typecheck: exit 0.

Full dogfood (owed for the narrowing arm), pnpm --filter @objectstack/dogfood test under the shared lock on 1788eefbb: VERDICT command-exit 0, Test Files 362 passed | 3 skipped (365), Tests 2922 passed | 28 skipped (2950).


Generated by Claude Code

… begin and revoke

ADR-0143 D5, ruling A (record 6548764795): connecting, reconnecting and
revoking an organization's connector is a human administrator's act. The
door's begin (POST) and revoke (DELETE) legs refuse a caller admitted by
an API key or an OAuth access token, or carrying an agent envelope, with
403 PERMISSION_DENIED and a sentence naming its kind and the rule. The
read leg, the callback, the capability holders and the system bypass are
unchanged.

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/runtime, touching 8 documentable anchor(s).

8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/data-api.mdx (via api_key (symbol, a field of const object CONNECTOR_CONNECTION_DELEGATED_CALLER_NAMES), api_key (literal, a string literal in ConnectorConnectionDelegatedCaller; a string literal in connectorConnectionDelegatedCaller))
  • content/docs/automation/connectors.mdx (via api_key (symbol, a field of const object CONNECTOR_CONNECTION_DELEGATED_CALLER_NAMES), api_key (literal, a string literal in ConnectorConnectionDelegatedCaller; a string literal in connectorConnectionDelegatedCaller))
  • content/docs/data-modeling/field-type-decision-tree.mdx (via api_key (symbol, a field of const object CONNECTOR_CONNECTION_DELEGATED_CALLER_NAMES), api_key (literal, a string literal in ConnectorConnectionDelegatedCaller; a string literal in connectorConnectionDelegatedCaller))
  • content/docs/data-modeling/field-types.mdx (via api_key (symbol, a field of const object CONNECTOR_CONNECTION_DELEGATED_CALLER_NAMES), api_key (literal, a string literal in ConnectorConnectionDelegatedCaller; a string literal in connectorConnectionDelegatedCaller))
  • content/docs/permissions/system-context.mdx (via handleAutomationRequest (symbol, a top-level function), refuseUngrantedConnectorConnectionAccess (symbol, a top-level function))
  • content/docs/protocol/kernel/config-resolution.mdx (via api_key (symbol, a field of const object CONNECTOR_CONNECTION_DELEGATED_CALLER_NAMES), api_key (literal, a string literal in ConnectorConnectionDelegatedCaller; a string literal in connectorConnectionDelegatedCaller))
  • content/docs/protocol/objectql/types.mdx (via api_key (symbol, a field of const object CONNECTOR_CONNECTION_DELEGATED_CALLER_NAMES), api_key (literal, a string literal in ConnectorConnectionDelegatedCaller; a string literal in connectorConnectionDelegatedCaller))
  • content/docs/protocol/objectui/concept.mdx (via api_key (symbol, a field of const object CONNECTOR_CONNECTION_DELEGATED_CALLER_NAMES), api_key (literal, a string literal in ConnectorConnectionDelegatedCaller; a string literal in connectorConnectionDelegatedCaller))
What this run could not see
  • 2 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 — 28 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 9b1dc992c7cdfffd2d4f20581fc817f18981601c → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 288e305e8217149859a0ee5ebb5c2de16d56b216 — the merge of head 1788eefbbda8bab95b32b3c50eacc5c61795bf67 into base 9b1dc992c7cdfffd2d4f20581fc817f18981601c, 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 288e305e8217149859a0ee5ebb5c2de16d56b216 && git checkout 288e305e8217149859a0ee5ebb5c2de16d56b216
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9b1dc992c7cdfffd2d4f20581fc817f18981601c 1788eefbbda8bab95b32b3c50eacc5c61795bf67 && git checkout -B drift-repro 9b1dc992c7cdfffd2d4f20581fc817f18981601c && git merge --no-ff 1788eefbbda8bab95b32b3c50eacc5c61795bf67

node scripts/docs-audit/affected-docs.mjs --json 9b1dc992c7cdfffd2d4f20581fc817f18981601c

⚠️ 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 9b1dc992c7cdfffd2d4f20581fc817f18981601c → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

triage#1 · os-tesla · session_01L9qibw58ehQ2gRHP2h2Puz

Contract-review-claim: session_01L9qibw58ehQ2gRHP2h2Puz · head 1788eefbbda8bab95b32b3c50eacc5c61795bf67 · 2026-10-31T21:45Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

triage#1 · os-tesla · session_01L9qibw58ehQ2gRHP2h2Puz

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 1788eefbbda8bab95b32b3c50eacc5c61795bf67
Local-runs: none
Scope: PR #28474 (Fixes #28468; ruling A 6548764795 on the #28427 agent finding, Tesla 「同意」 on batch #435; claim 6549537700), 4 files, +202/−4 against merge-base 9b1dc992, mergeable. Tier S: no docs/adr/**, no skills/**, no release file; the one prose change is a content/docs paragraph.
Inputs: card #28468 (the finding, the ruling letter and its Wanted list), the PR body (速读, ablation table, verification), the full diff, refs/review/pr-28474 for isConnectorConnectionDoor (the callback GET is a door route; the second call site at L3377 is the responder, not the gate) and apiForwardCredentialKind (oauthScopes array → oauth, else admission.credential), origin/main for the unchanged symbols, check-runs (read 2026-10-31T22:03Z: all 50 latest-per-name check-runs completed, 36 success + 14 skipped, none failed, none running) — 50 checks

① Ruling → code

  • Letter A: begin (connect, reconnect) and revoke refuse a delegated or agent caller by kind, naming the kind, whatever capability it holds; read leg and callback unchanged; isSystem bypass unchanged; holders unchanged. The diff is exactly that: connectorConnectionDelegatedCaller(context) names agent (principalKind: 'agent' or onBehalfOf), then oauth / api_key through the forward table's own apiForwardCredentialKind, so the two doors read one credential the same way; refuseUngrantedConnectorConnectionAccess(deps, context, method) runs the branch after the isSystem return and before the capability read, for POST and DELETE only.
  • Routing holds: isConnectorConnectionDoor admits GET/POST/DELETE …/:name/connection and GET …/oauth/callback; the method guard leaves both GET arms on the capability gate alone, so the read leg and the callback answer as before (pinned for all three kinds).
  • Status and code are the gate's existing FLOW_WRITE_DENY_STATUS / FLOW_WRITE_DENY_CODE (403 PERMISSION_DENIED); the sentence names the kind and the rule (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 B refusal-table precedent: one sentence per kind, from one table). No new permission name, no spec key, no ADR edit (D5's "bound to the initiating session" is what this enforces).

② Declarations

③ Pins and ablations

  • Fixtures are the real assembleExecutionContext envelopes (agent over OAuth client, OAuth token without client, API-key admission), each pinned to still hold ORG_ADMIN_GRANT, so the refusal is proven to ignore the capability; each kind refused on begin and on revoke with the exact sentence and no service member reached; the same human signed in admitted on both legs with tenantId: 'org_a' handed through; system call admitted even with an API-key admission; read leg + callback 200 for all three kinds. 31/31.
  • Four ablations on automation.ts via ablation-replace.mjs, each red then restored (agent branch 2/31, oauth 2/31, api_key 2/31, whole rule 3/31); the body states why the agent ablation reds on the name rather than the status (the agent envelope also carries oauthScopes). 91 derived families exit 0, full dogfood green.

④ Traps and should-fix

  • The oauth kind is read from oauthScopes on the envelope, not from admission.credential (which is 'api_key' | 'session' only): a session that also carries an OAuth envelope is refused as an OAuth token. That is the forward table's reading too, so it is consistent, but a card that widens admission.credential must keep apiForwardCredentialKind the one reader.
  • content/docs/automation/connectors.mdx names "an agent acting for them (an OAuth client) or an API key"; the third kind (a bare OAuth access token) is covered by the sentence's "whatever it holds" and the refusal text, not named in the doc. Fine for landing; a one-word docs follow-up at most.

Implemented-by: claude/project-thread-0wkobq
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

维护者速读

  • 改了什么:连接器连接门的「连接/重连」和「撤销」两步,现在按名称拒绝代理人(agent)、OAuth 令牌和 API 密钥调用方,不论它持有什么能力。返回 403 PERMISSION_DENIED,提示语点明调用方的类型。读取连接状态和回调这两步不变,人类管理员和系统调用也不变。
  • 为什么改:你在 20:29 对批次 [WIP] Update all package versions to 0.7 #435 的「同意」(裁定 A:连接和撤销是人类管理员的操作)。
  • 风险与回滚:这是收窄,只影响代理人调用方;人类管理员直接操作不受影响。回滚即 revert。
  • 先看:packages/runtime/src/domains/automation.ts 中的 connectorConnectionDelegatedCaller 和 refuseUngrantedConnectorConnectionAccess。
  • 两分钟验证:pnpm --filter @objectstack/runtime exec vitest run src/domains/automation-connector-connection-door.test.ts,31 项全部通过。
  • 需要你做的:无,席位落地。

席位意见:契约复审 PASS,按裁定 A 原样实现。可以直接落地。

席位核对

ACCEPT · seat domain:server#1 · head 1788eefb · 2026-10-31T22:04Z

  1. Shape: Fixes #28468. Draft, base main, 4 files, +202/−4, with a patch changeset for @objectstack/runtime (a BREAKING note and the ADR-0087 not-required marker). No packages/spec and no ADR path.
  2. Ruling: A 6548764795.
    • Begin and revoke refuse agent, oauth and api_key callers by name, whatever capability they hold. The kind is read through the forward table's own apiForwardCredentialKind.
    • The read leg and the callback are unchanged, and so are holders and isSystem.
    • The refusal uses the gate's existing 403 status and code.
  3. Contract review: lane PASS 6549735852 at this head. It declares Clause-②: no (narrowing) and Metadata-protocol: none.
  4. Gates / check-runs:
    • Dev report 6549446150: the live premise held, the door file passed 31/31, runtime passed 494/494 files, four ablations went red, all 91 derived gates exited 0, and the full dogfood run was green.
    • On head 1788eefb: 50 check-runs completed, 36 success, 14 skipped, 0 failed.
  5. Accepted deviations: the review's docs note stays as written. The bare OAuth token is covered by the refusal text, and the docs line does not name it separately.
  6. Landing: check-governed-merges --pr 28474 reports NOT governed. The seat readies the PR and arms auto-merge under the maintainer's 「按 pm-dispatch 审过的pr你可以自主落地」.

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/m tests tooling

Projects

None yet

1 participant