Pārlūkot izejas kodu

feat(authorization): obtain a credential by asking the human

Some credentials cannot be configured, only obtained: getting one means
a conversation — open this page, paste that code, pick an account. The
new seam owns that conversation and the one-attempt-per-key lifecycle,
and never the protocol, so a second authorization protocol arrives as
another flow rather than as another seam.

A flow is registered under the CredentialKey it writes, which is also
how the seam knows which plugin answers for the format inside that
record. The flow owns the write: run() resolving means the record is
already committed through ctx.credentials, and the seam confirms it.
That keeps a library persisting through its own store adapter the
single writer instead of being copied back out and written twice.

The interaction travels with the request rather than a registry,
because whoever starts an authorization is the one who can talk to the
human about it. A request already withdrawn never claims the key and
never starts the flow — relying on each flow to check its signal before
the first await would let one that does not hang holding the key.
Yichen Jiang 1 mēnesi atpakaļ
vecāks
revīzija
732a7361f5
36 mainītis faili ar 1468 papildinājumiem un 23 dzēšanām
  1. 1 1
      AGENTS.md
  2. 2 2
      docs/capability-seams.i18n.yaml
  3. 5 0
      docs/capability-seams.md
  4. 5 0
      docs/capability-seams.zh.md
  5. 2 2
      docs/config-catalog.i18n.yaml
  6. 1 0
      docs/config-catalog.md
  7. 1 0
      docs/config-catalog.zh.md
  8. 2 2
      docs/event-producer-consumer.i18n.yaml
  9. 2 1
      docs/event-producer-consumer.md
  10. 2 1
      docs/event-producer-consumer.zh.md
  11. 2 2
      docs/subsystems/credentials.i18n.yaml
  12. 83 0
      docs/subsystems/credentials.md
  13. 83 0
      docs/subsystems/credentials.zh.md
  14. 1 1
      packages/README.md
  15. 1 1
      packages/README.zh.md
  16. 2 2
      packages/credentials/README.i18n.yaml
  17. 5 4
      packages/credentials/README.md
  18. 5 4
      packages/credentials/README.zh.md
  19. 6 0
      packages/credentials/authorization/README.i18n.yaml
  20. 74 0
      packages/credentials/authorization/README.md
  21. 74 0
      packages/credentials/authorization/README.zh.md
  22. 51 0
      packages/credentials/authorization/package.json
  23. 324 0
      packages/credentials/authorization/src/index.ts
  24. 45 0
      packages/credentials/authorization/src/invariant.ts
  25. 91 0
      packages/credentials/authorization/src/types.ts
  26. 283 0
      packages/credentials/authorization/tests/authorization.spec.ts
  27. 87 0
      packages/credentials/authorization/tests/invariant.spec.ts
  28. 67 0
      packages/credentials/authorization/tests/memory.ts
  29. 27 0
      packages/credentials/authorization/tsconfig.json
  30. 94 0
      packages/extensions/tool-cordis/src/api-catalog.ts
  31. 15 0
      pnpm-lock.yaml
  32. 13 0
      scripts/gen-cordis-catalog.ts
  33. 9 0
      scripts/gen-doc-graphs.ts
  34. 1 0
      scripts/verify-package-readme-model-experience.ts
  35. 1 0
      tsconfig.base.json
  36. 1 0
      tsconfig.host.json

+ 1 - 1
AGENTS.md

@@ -37,7 +37,7 @@ packages/    @deepseek-ai/dsh-<pkg> workspaces at packages/<group>/<pkg>/
   session/     durable session data: persistence, projection, titles, telemetry
   session/     durable session data: persistence, projection, titles, telemetry
   identity/    anonymous identity
   identity/    anonymous identity
   settings/    user-settings capability + file provider
   settings/    user-settings capability + file provider
-  credentials/ credential-reference capability + env/.env provider
+  credentials/ credential/authorization capabilities + env/.env provider
   acp/         automation-only Agent Client Protocol server
   acp/         automation-only Agent Client Protocol server
   interaction/ approval/interaction capabilities, permission, commands, ask-user
   interaction/ approval/interaction capabilities, permission, commands, ask-user
   boot/        shared app-bin glue
   boot/        shared app-bin glue

+ 2 - 2
docs/capability-seams.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/capability-seams.md
 #   pnpm run verify-translation-pairing --write docs/capability-seams.md
-capability-seams.md: a84a6d6e0836524e1f39f2fd067ae1f6570741a5
-capability-seams.zh.md: b6b0aba72729f9b9b752347ab00cd76a45c248d9
+capability-seams.md: 9e99ebbdc0e3af22f9939c690ead479d4d20b00c
+capability-seams.zh.md: 611902310e3472e05eaa92985011eb852bbeee6d

+ 5 - 0
docs/capability-seams.md

@@ -51,6 +51,8 @@ flowchart LR
   pkg_credentials["credentials"]
   pkg_credentials["credentials"]
   svc_credentials["ctx.credentials<br/>Credential seam"]
   svc_credentials["ctx.credentials<br/>Credential seam"]
   pkg_credentials_local["credentials-local"]
   pkg_credentials_local["credentials-local"]
+  pkg_authorization["authorization"]
+  svc_authorization["ctx.authorization<br/>Authorization flow registry"]
   pkg_session_telemetry["session-telemetry"]
   pkg_session_telemetry["session-telemetry"]
   svc_sessionTelemetry["ctx.sessionTelemetry<br/>Session telemetry seam"]
   svc_sessionTelemetry["ctx.sessionTelemetry<br/>Session telemetry seam"]
   pkg_session_telemetry_otel["session-telemetry-otel"]
   pkg_session_telemetry_otel["session-telemetry-otel"]
@@ -210,6 +212,7 @@ flowchart LR
   pkg_approval --> svc_approval
   pkg_approval --> svc_approval
   pkg_attachment --> svc_attachments
   pkg_attachment --> svc_attachments
   pkg_attachment_local --> svc_attachments
   pkg_attachment_local --> svc_attachments
+  pkg_authorization --> svc_authorization
   pkg_bash_local --> svc_shell
   pkg_bash_local --> svc_shell
   pkg_bash_sandbox --> svc_shell
   pkg_bash_sandbox --> svc_shell
   pkg_code_runtime --> svc_codeRuntime
   pkg_code_runtime --> svc_codeRuntime
@@ -315,6 +318,7 @@ flowchart LR
   svc_approval --> pkg_tools
   svc_approval --> pkg_tools
   svc_attachments --> pkg_host_runtime
   svc_attachments --> pkg_host_runtime
   svc_attachments --> pkg_llm_pi_ai
   svc_attachments --> pkg_llm_pi_ai
+  svc_authorization --> pkg_llm_pi_ai
   svc_clientModules --> pkg_hmr
   svc_clientModules --> pkg_hmr
   svc_codeRuntime --> pkg_tools
   svc_codeRuntime --> pkg_tools
   svc_compaction --> pkg_compaction_basic
   svc_compaction --> pkg_compaction_basic
@@ -432,6 +436,7 @@ flowchart LR
 | `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session/session-persistence) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/shell/tool-bash), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`message-feedback`](../packages/feedback/message-feedback) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. |
 | `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session/session-persistence) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/shell/tool-bash), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`message-feedback`](../packages/feedback/message-feedback) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. |
 | `ctx.settings` | `seam` | [`settings`](../packages/settings/settings) | [`settings-file`](../packages/settings/settings-file) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), `apiproxy` | - | Plugins register namespace schemas and resolve layered values; providers store the raw document. The LLM adapters register their entry config as the composition base under the user section; the web gateway serves redacted layered descriptors and writes the user layer. |
 | `ctx.settings` | `seam` | [`settings`](../packages/settings/settings) | [`settings-file`](../packages/settings/settings-file) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), `apiproxy` | - | Plugins register namespace schemas and resolve layered values; providers store the raw document. The LLM adapters register their entry config as the composition base under the user section; the web gateway serves redacted layered descriptors and writes the user layer. |
 | `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), `apiproxy` | - | Configuration carries references to secrets; providers own the values. Consumers resolve per operation, so a rotated credential reaches the very next request; the web gateway exposes value-free views and write-only storage. |
 | `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), `apiproxy` | - | Configuration carries references to secrets; providers own the values. Consumers resolve per operation, so a rotated credential reaches the very next request; the web gateway exposes value-free views and write-only storage. |
+| `ctx.authorization` | `seam` | [`authorization`](../packages/credentials/authorization) | - | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | Flows are registered by the plugin that knows how to obtain one credential and keyed by the record they write; the seam owns the conversation and the one-attempt-per-key lifecycle, never the protocol. |
 | `ctx.sessionTelemetry` | `seam` | [`session-telemetry`](../packages/session/session-telemetry) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | - | - | The seam captures, redacts, and hands session records to one backend; nothing else consumes the service — its output leaves the process. |
 | `ctx.sessionTelemetry` | `seam` | [`session-telemetry`](../packages/session/session-telemetry) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | - | - | The seam captures, redacts, and hands session records to one backend; nothing else consumes the service — its output leaves the process. |
 | `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | Backends register side by side under names; data forms (domain first) mount on the hub and translate typed operations into opaque KV-unit primitives. |
 | `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | Backends register side by side under names; data forms (domain first) mount on the hub and translate typed operations into opaque KV-unit primitives. |
 | `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace), [`message-feedback`](../packages/feedback/message-feedback) | - | Waits for every configured backend, then publishes the domain form as one lifecycle-bound service for typed durable state. |
 | `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace), [`message-feedback`](../packages/feedback/message-feedback) | - | Waits for every configured backend, then publishes the domain form as one lifecycle-bound service for typed durable state. |

+ 5 - 0
docs/capability-seams.zh.md

@@ -53,6 +53,8 @@ flowchart LR
   pkg_credentials["credentials"]
   pkg_credentials["credentials"]
   svc_credentials["ctx.credentials<br/>Credential seam"]
   svc_credentials["ctx.credentials<br/>Credential seam"]
   pkg_credentials_local["credentials-local"]
   pkg_credentials_local["credentials-local"]
+  pkg_authorization["authorization"]
+  svc_authorization["ctx.authorization<br/>Authorization flow registry"]
   pkg_session_telemetry["session-telemetry"]
   pkg_session_telemetry["session-telemetry"]
   svc_sessionTelemetry["ctx.sessionTelemetry<br/>Session telemetry seam"]
   svc_sessionTelemetry["ctx.sessionTelemetry<br/>Session telemetry seam"]
   pkg_session_telemetry_otel["session-telemetry-otel"]
   pkg_session_telemetry_otel["session-telemetry-otel"]
@@ -212,6 +214,7 @@ flowchart LR
   pkg_approval --> svc_approval
   pkg_approval --> svc_approval
   pkg_attachment --> svc_attachments
   pkg_attachment --> svc_attachments
   pkg_attachment_local --> svc_attachments
   pkg_attachment_local --> svc_attachments
+  pkg_authorization --> svc_authorization
   pkg_bash_local --> svc_shell
   pkg_bash_local --> svc_shell
   pkg_bash_sandbox --> svc_shell
   pkg_bash_sandbox --> svc_shell
   pkg_code_runtime --> svc_codeRuntime
   pkg_code_runtime --> svc_codeRuntime
@@ -317,6 +320,7 @@ flowchart LR
   svc_approval --> pkg_tools
   svc_approval --> pkg_tools
   svc_attachments --> pkg_host_runtime
   svc_attachments --> pkg_host_runtime
   svc_attachments --> pkg_llm_pi_ai
   svc_attachments --> pkg_llm_pi_ai
+  svc_authorization --> pkg_llm_pi_ai
   svc_clientModules --> pkg_hmr
   svc_clientModules --> pkg_hmr
   svc_codeRuntime --> pkg_tools
   svc_codeRuntime --> pkg_tools
   svc_compaction --> pkg_compaction_basic
   svc_compaction --> pkg_compaction_basic
@@ -434,6 +438,7 @@ flowchart LR
 | `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session/session-persistence) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/shell/tool-bash), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`message-feedback`](../packages/feedback/message-feedback) | - | 各后端持久化同一套 SessionEvent 词汇;应用在组合时选择后端。 |
 | `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session/session-persistence) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/shell/tool-bash), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`message-feedback`](../packages/feedback/message-feedback) | - | 各后端持久化同一套 SessionEvent 词汇;应用在组合时选择后端。 |
 | `ctx.settings` | `seam` | [`settings`](../packages/settings/settings) | [`settings-file`](../packages/settings/settings-file) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), `apiproxy` | - | 插件注册命名空间 schema 并解析分层值;提供方存储原始文档。LLM(大语言模型)适配器在用户分区下将其入口配置注册为组合基础;Web 网关提供经过脱敏的分层描述符,并写入用户层。 |
 | `ctx.settings` | `seam` | [`settings`](../packages/settings/settings) | [`settings-file`](../packages/settings/settings-file) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), `apiproxy` | - | 插件注册命名空间 schema 并解析分层值;提供方存储原始文档。LLM(大语言模型)适配器在用户分区下将其入口配置注册为组合基础;Web 网关提供经过脱敏的分层描述符,并写入用户层。 |
 | `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), `apiproxy` | - | 配置携带对机密信息的引用;提供方拥有实际值。消费方按操作解析,因此轮换后的凭据会在紧接着的下一次请求中生效;Web 网关提供不含实际值的视图和只写存储。 |
 | `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), `apiproxy` | - | 配置携带对机密信息的引用;提供方拥有实际值。消费方按操作解析,因此轮换后的凭据会在紧接着的下一次请求中生效;Web 网关提供不含实际值的视图和只写存储。 |
+| `ctx.authorization` | `seam` | [`authorization`](../packages/credentials/authorization) | - | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | flow 由知道如何取得某份凭据的插件注册,并以其写入的记录为键;seam 拥有这段对话与"每个键同时只跑一次尝试"的生命周期,而非协议本身。 |
 | `ctx.sessionTelemetry` | `seam` | [`session-telemetry`](../packages/session/session-telemetry) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | - | - | 该 seam 捕获会话记录、进行脱敏并交给一个后端;没有其他组件消费该服务,其输出会离开当前进程。 |
 | `ctx.sessionTelemetry` | `seam` | [`session-telemetry`](../packages/session/session-telemetry) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | - | - | 该 seam 捕获会话记录、进行脱敏并交给一个后端;没有其他组件消费该服务,其输出会离开当前进程。 |
 | `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | 各后端以不同名称并列注册;数据形态(领域优先)挂载到枢纽上,并将类型化操作转换为不透明的 KV 单元原语。 |
 | `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | 各后端以不同名称并列注册;数据形态(领域优先)挂载到枢纽上,并将类型化操作转换为不透明的 KV 单元原语。 |
 | `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace), [`message-feedback`](../packages/feedback/message-feedback) | - | 等待所有已配置后端就绪,然后将领域形态发布为一个受生命周期约束的服务,用于类型化持久状态。 |
 | `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace), [`message-feedback`](../packages/feedback/message-feedback) | - | 等待所有已配置后端就绪,然后将领域形态发布为一个受生命周期约束的服务,用于类型化持久状态。 |

+ 2 - 2
docs/config-catalog.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/config-catalog.md
 #   pnpm run verify-translation-pairing --write docs/config-catalog.md
-config-catalog.md: f4e813238b89ef32c097f9ee274347e37ba11efc
-config-catalog.zh.md: 4eec1d6d172365d9a16f420a639d09a3bbae1147
+config-catalog.md: b93b91f12703ea52225e1202d6cdc376a0b96331
+config-catalog.zh.md: 2562cae37ad4ebce7646b80d7c54cc59bcfd0abf

+ 1 - 0
docs/config-catalog.md

@@ -3192,6 +3192,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
 - `@deepseek-ai/dsh-agent` ([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts))
 - `@deepseek-ai/dsh-agent` ([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts))
 - `@deepseek-ai/dsh-api-gateway` — requires `typert` ([`packages/api/gateway/src/index.ts`](../packages/api/gateway/src/index.ts))
 - `@deepseek-ai/dsh-api-gateway` — requires `typert` ([`packages/api/gateway/src/index.ts`](../packages/api/gateway/src/index.ts))
 - `@deepseek-ai/dsh-api-remotes` ([`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts))
 - `@deepseek-ai/dsh-api-remotes` ([`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts))
+- `@deepseek-ai/dsh-authorization` — requires `credentials` ([`packages/credentials/authorization/src/index.ts`](../packages/credentials/authorization/src/index.ts))
 - `@deepseek-ai/dsh-client-locale` ([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts))
 - `@deepseek-ai/dsh-client-locale` ([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts))
 - `@deepseek-ai/dsh-client-modules` — requires `webServer` · `loader` ([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts))
 - `@deepseek-ai/dsh-client-modules` — requires `webServer` · `loader` ([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts))
 - `@deepseek-ai/dsh-client-runtime` ([`packages/client/runtime/src/index.ts`](../packages/client/runtime/src/index.ts))
 - `@deepseek-ai/dsh-client-runtime` ([`packages/client/runtime/src/index.ts`](../packages/client/runtime/src/index.ts))

+ 1 - 0
docs/config-catalog.zh.md

@@ -3195,6 +3195,7 @@ export interface Config {
 - `@deepseek-ai/dsh-agent`([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts))
 - `@deepseek-ai/dsh-agent`([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts))
 - `@deepseek-ai/dsh-api-gateway` — 需要 `typert`([`packages/api/gateway/src/index.ts`](../packages/api/gateway/src/index.ts))
 - `@deepseek-ai/dsh-api-gateway` — 需要 `typert`([`packages/api/gateway/src/index.ts`](../packages/api/gateway/src/index.ts))
 - `@deepseek-ai/dsh-api-remotes`([`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts))
 - `@deepseek-ai/dsh-api-remotes`([`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts))
+- `@deepseek-ai/dsh-authorization` — 需要 `credentials`([`packages/credentials/authorization/src/index.ts`](../packages/credentials/authorization/src/index.ts))
 - `@deepseek-ai/dsh-client-locale`([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts))
 - `@deepseek-ai/dsh-client-locale`([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts))
 - `@deepseek-ai/dsh-client-modules` — 需要 `webServer` · `loader`([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts))
 - `@deepseek-ai/dsh-client-modules` — 需要 `webServer` · `loader`([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts))
 - `@deepseek-ai/dsh-client-runtime`([`packages/client/runtime/src/index.ts`](../packages/client/runtime/src/index.ts))
 - `@deepseek-ai/dsh-client-runtime`([`packages/client/runtime/src/index.ts`](../packages/client/runtime/src/index.ts))

+ 2 - 2
docs/event-producer-consumer.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
 #   pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
-event-producer-consumer.md: 11dbce044cdbcae1f7ee49a6b5b4d0d369f80bf8
-event-producer-consumer.zh.md: ef9b7ead7e9951b68cbf84f6031d60411e46480f
+event-producer-consumer.md: f5ead70b691b43bf9de4507d6753e7066c2d2013
+event-producer-consumer.zh.md: 7aa0f03492561e77c94ab36b5da1ee4b4346d861

+ 2 - 1
docs/event-producer-consumer.md

@@ -22,7 +22,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:178`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, `apiproxy`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server` |
 | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:178`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, `apiproxy`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server` |
 | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:278`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
 | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:278`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
 | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/index.ts:30`](../packages/interaction/user-approval/src/index.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` |
 | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/index.ts:30`](../packages/interaction/user-approval/src/index.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` |
-| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `apiproxy` |
+| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`emit`) | [`authorization`](../packages/credentials/authorization) |
+| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:72`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `apiproxy` |
 | `cordis/dynamic-package` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:379`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` |
 | `cordis/dynamic-package` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:379`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` |
 | `cordis/dynamic-retract` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:385`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` |
 | `cordis/dynamic-retract` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:385`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` |
 | `cordis/inspect-query` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:391`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` |
 | `cordis/inspect-query` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:391`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` |

+ 2 - 1
docs/event-producer-consumer.zh.md

@@ -24,7 +24,8 @@
 | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:178`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, `apiproxy`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server` |
 | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:178`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, `apiproxy`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server` |
 | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:278`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
 | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:278`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
 | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/index.ts:30`](../packages/interaction/user-approval/src/index.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` |
 | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/index.ts:30`](../packages/interaction/user-approval/src/index.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` |
-| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `apiproxy` |
+| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`emit`) | [`authorization`](../packages/credentials/authorization) |
+| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:72`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `apiproxy` |
 | `cordis/dynamic-package` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:379`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` |
 | `cordis/dynamic-package` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:379`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` |
 | `cordis/dynamic-retract` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:385`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` |
 | `cordis/dynamic-retract` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:385`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` |
 | `cordis/inspect-query` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:391`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` |
 | `cordis/inspect-query` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:391`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` |

+ 2 - 2
docs/subsystems/credentials.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/credentials.md
 #   pnpm run verify-translation-pairing --write docs/subsystems/credentials.md
-credentials.md: 59ab18d3f9fb9db7668f766484fc6a7ac7d3c950
-credentials.zh.md: 5b941c7f360bb3904f91a16bdfd862f5d13d6b39
+credentials.md: aa3230379205eabec0b9e51596bbbbfb7ef491b9
+credentials.zh.md: ce9db1709555950d4f4e30798c560922a54d8674

+ 83 - 0
docs/subsystems/credentials.md

@@ -57,6 +57,65 @@ interface CredentialInfo {
 
 
 Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — this section is byte-identical in both language sides of the page. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md).
 Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — this section is byte-identical in both language sides of the page. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md).
 
 
+<a id="ctxauthorization--authorizationservice"></a>
+
+### `ctx.authorization` — `AuthorizationService`
+
+`ctx.authorization`: a registry of credential-obtaining flows, one attempt at a time per key.
+
+```ts cordis-catalog
+/**
+ * Offer a way to obtain one credential. One flow per key: two plugins
+ * claiming the same key would each write a record in their own format, and
+ * whichever ran last would leave the other reading a payload it cannot parse.
+ *
+ * @param flow - the key it writes, its label, its methods, and its runner.
+ * @returns Disposer that withdraws this flow.
+ * @throws {AuthorizationError} code `DUPLICATE_FLOW` when the key is already claimed.
+ */
+registerFlow(flow: AuthorizationFlow): () => void
+
+/**
+ * Every registered flow, for a surface listing what can be authorized.
+ * @returns one entry per flow, in registration order.
+ */
+list(): readonly AuthorizationEntry[]
+
+/**
+ * One registered flow.
+ * @param key - the credential record to ask about.
+ * @returns the entry, or undefined when no flow claims that key.
+ */
+describe(key: CredentialKey): AuthorizationEntry | undefined
+
+/**
+ * Withdraw the attempt running for a key, if any. Separate from the
+ * request's own signal because a request/response transport answers a Cancel
+ * button on a second call, with no handle on the first one's signal.
+ * @param key - the credential record whose attempt should stop.
+ */
+cancel(key: CredentialKey): void
+
+/**
+ * Run one attempt to authorize a key, and report how it ended.
+ *
+ * One attempt per key at a time. A second caller is refused rather than
+ * joined: the two would be prompting different humans through the same flow,
+ * and the second would answer questions the first was asked.
+ *
+ * @param request - the key, the method, the surface, and the cancel signal.
+ * @returns `authorized` once the flow's record is committed and observed,
+ *   or `cancelled` when the human or the caller withdrew.
+ * @throws {AuthorizationError} code `NO_FLOW` when nothing claims the key,
+ *   `UNKNOWN_METHOD` when the named method is not one the flow offers,
+ *   `ALREADY_IN_FLIGHT` when an attempt is already running for the key, or
+ *   `NOT_COMMITTED` when the flow resolved without leaving a record behind.
+ */
+async begin(request: AuthorizationRequest): Promise<AuthorizationOutcome>
+```
+
+Source: [`packages/credentials/authorization/src/index.ts:163`](../../packages/credentials/authorization/src/index.ts)
+
 <a id="ctxcredentials--credentialprovider-abstract-seam"></a>
 <a id="ctxcredentials--credentialprovider-abstract-seam"></a>
 
 
 ### `ctx.credentials` — `CredentialProvider` (abstract seam)
 ### `ctx.credentials` — `CredentialProvider` (abstract seam)
@@ -151,6 +210,30 @@ abstract deleteRecord(key: CredentialKey): Promise<void>
 
 
 Source: [`packages/credentials/credentials/src/index.ts:141`](../../packages/credentials/credentials/src/index.ts)
 Source: [`packages/credentials/credentials/src/index.ts:141`](../../packages/credentials/credentials/src/index.ts)
 
 
+<a id="authorization-events"></a>
+
+### `authorization/*` events
+
+<a id="authorizationsettled--emit"></a>
+
+#### `authorization/settled` — emit
+
+One authorization attempt has finished and released its key. Fires for every terminal outcome, failures included, so a surface watching a key it did not start (a second browser tab) learns the attempt is over.
+
+```ts cordis-catalog
+/**
+ * One authorization attempt has finished and released its key. Fires for
+ * every terminal outcome, failures included, so a surface watching a key it
+ * did not start (a second browser tab) learns the attempt is over.
+ * @mode emit
+ * @param key - the credential record the finished attempt was authorizing.
+ * @param settlement - how it ended, including the `failed` case its caller sees as a thrown error.
+ */
+'authorization/settled'(key: CredentialKey, settlement: AuthorizationSettlement): void
+```
+
+Source: [`packages/credentials/authorization/src/index.ts:57`](../../packages/credentials/authorization/src/index.ts)
+
 <a id="credentials-events"></a>
 <a id="credentials-events"></a>
 
 
 ### `credentials/*` events
 ### `credentials/*` events

+ 83 - 0
docs/subsystems/credentials.zh.md

@@ -57,6 +57,65 @@ interface CredentialInfo {
 
 
 Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — this section is byte-identical in both language sides of the page. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md).
 Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — this section is byte-identical in both language sides of the page. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md).
 
 
+<a id="ctxauthorization--authorizationservice"></a>
+
+### `ctx.authorization` — `AuthorizationService`
+
+`ctx.authorization`: a registry of credential-obtaining flows, one attempt at a time per key.
+
+```ts cordis-catalog
+/**
+ * Offer a way to obtain one credential. One flow per key: two plugins
+ * claiming the same key would each write a record in their own format, and
+ * whichever ran last would leave the other reading a payload it cannot parse.
+ *
+ * @param flow - the key it writes, its label, its methods, and its runner.
+ * @returns Disposer that withdraws this flow.
+ * @throws {AuthorizationError} code `DUPLICATE_FLOW` when the key is already claimed.
+ */
+registerFlow(flow: AuthorizationFlow): () => void
+
+/**
+ * Every registered flow, for a surface listing what can be authorized.
+ * @returns one entry per flow, in registration order.
+ */
+list(): readonly AuthorizationEntry[]
+
+/**
+ * One registered flow.
+ * @param key - the credential record to ask about.
+ * @returns the entry, or undefined when no flow claims that key.
+ */
+describe(key: CredentialKey): AuthorizationEntry | undefined
+
+/**
+ * Withdraw the attempt running for a key, if any. Separate from the
+ * request's own signal because a request/response transport answers a Cancel
+ * button on a second call, with no handle on the first one's signal.
+ * @param key - the credential record whose attempt should stop.
+ */
+cancel(key: CredentialKey): void
+
+/**
+ * Run one attempt to authorize a key, and report how it ended.
+ *
+ * One attempt per key at a time. A second caller is refused rather than
+ * joined: the two would be prompting different humans through the same flow,
+ * and the second would answer questions the first was asked.
+ *
+ * @param request - the key, the method, the surface, and the cancel signal.
+ * @returns `authorized` once the flow's record is committed and observed,
+ *   or `cancelled` when the human or the caller withdrew.
+ * @throws {AuthorizationError} code `NO_FLOW` when nothing claims the key,
+ *   `UNKNOWN_METHOD` when the named method is not one the flow offers,
+ *   `ALREADY_IN_FLIGHT` when an attempt is already running for the key, or
+ *   `NOT_COMMITTED` when the flow resolved without leaving a record behind.
+ */
+async begin(request: AuthorizationRequest): Promise<AuthorizationOutcome>
+```
+
+Source: [`packages/credentials/authorization/src/index.ts:163`](../../packages/credentials/authorization/src/index.ts)
+
 <a id="ctxcredentials--credentialprovider-abstract-seam"></a>
 <a id="ctxcredentials--credentialprovider-abstract-seam"></a>
 
 
 ### `ctx.credentials` — `CredentialProvider` (abstract seam)
 ### `ctx.credentials` — `CredentialProvider` (abstract seam)
@@ -151,6 +210,30 @@ abstract deleteRecord(key: CredentialKey): Promise<void>
 
 
 Source: [`packages/credentials/credentials/src/index.ts:141`](../../packages/credentials/credentials/src/index.ts)
 Source: [`packages/credentials/credentials/src/index.ts:141`](../../packages/credentials/credentials/src/index.ts)
 
 
+<a id="authorization-events"></a>
+
+### `authorization/*` events
+
+<a id="authorizationsettled--emit"></a>
+
+#### `authorization/settled` — emit
+
+One authorization attempt has finished and released its key. Fires for every terminal outcome, failures included, so a surface watching a key it did not start (a second browser tab) learns the attempt is over.
+
+```ts cordis-catalog
+/**
+ * One authorization attempt has finished and released its key. Fires for
+ * every terminal outcome, failures included, so a surface watching a key it
+ * did not start (a second browser tab) learns the attempt is over.
+ * @mode emit
+ * @param key - the credential record the finished attempt was authorizing.
+ * @param settlement - how it ended, including the `failed` case its caller sees as a thrown error.
+ */
+'authorization/settled'(key: CredentialKey, settlement: AuthorizationSettlement): void
+```
+
+Source: [`packages/credentials/authorization/src/index.ts:57`](../../packages/credentials/authorization/src/index.ts)
+
 <a id="credentials-events"></a>
 <a id="credentials-events"></a>
 
 
 ### `credentials/*` events
 ### `credentials/*` events

+ 1 - 1
packages/README.md

@@ -46,7 +46,7 @@ Groups hold `packages/<group>/<pkg>/`; names stay `@deepseek-ai/dsh-<pkg>`. **Gr
 | [`session/`](session/README.md) | Durable session data plane: persistence seam + JSONL/SQLite backends, projection seam, log-backed titles, session reporting | Product — stable API |
 | [`session/`](session/README.md) | Durable session data plane: persistence seam + JSONL/SQLite backends, projection seam, log-backed titles, session reporting | Product — stable API |
 | [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, bounded reads, lineage, event relationships, semantic filtering, and SQLite full-text search | Product — stable API |
 | [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, bounded reads, lineage, event relationships, semantic filtering, and SQLite full-text search | Product — stable API |
 | [`settings/`](settings/README.md) | User-settings seam + file-backed provider | Product — stable API |
 | [`settings/`](settings/README.md) | User-settings seam + file-backed provider | Product — stable API |
-| [`credentials/`](credentials/README.md) | Credential-reference seam + env-over-`.env` provider | Product — stable API |
+| [`credentials/`](credentials/README.md) | Credential reference/record seam + env-over-`.env` provider + authorization flows | Product — stable API |
 | [`storage/`](storage/README.md) | Non-session storage hub + backends + domain form | Product — stable API |
 | [`storage/`](storage/README.md) | Non-session storage hub + backends + domain form | Product — stable API |
 | [`workspace/`](workspace/README.md) | Workspace entity | Product — stable API |
 | [`workspace/`](workspace/README.md) | Workspace entity | Product — stable API |
 | [`sdk/`](sdk/README.md) | Out-of-process runtime SDK: JSON-RPC protocol, TypeScript client, and server plugin | Product — stable API |
 | [`sdk/`](sdk/README.md) | Out-of-process runtime SDK: JSON-RPC protocol, TypeScript client, and server plugin | Product — stable API |

+ 1 - 1
packages/README.zh.md

@@ -46,7 +46,7 @@ npm scope 为 `@deepseek-ai/dsh-*`;Cordis `Service` 子类和函数插件通
 | [`session/`](session/README.md) | 持久会话数据平面:持久化 seam + JSONL/SQLite 后端、投影 seam、基于日志的标题、会话上报 | 产品:稳定 API |
 | [`session/`](session/README.md) | 持久会话数据平面:持久化 seam + JSONL/SQLite 后端、投影 seam、基于日志的标题、会话上报 | 产品:稳定 API |
 | [`session-query/`](session-query/README.md) | 会话检索系列:逻辑语料库、有界读取、血缘、事件关系、语义过滤和 SQLite 全文搜索 | 产品:稳定 API |
 | [`session-query/`](session-query/README.md) | 会话检索系列:逻辑语料库、有界读取、血缘、事件关系、语义过滤和 SQLite 全文搜索 | 产品:稳定 API |
 | [`settings/`](settings/README.md) | 用户设置 seam + 基于文件的提供方 | 产品:稳定 API |
 | [`settings/`](settings/README.md) | 用户设置 seam + 基于文件的提供方 | 产品:稳定 API |
-| [`credentials/`](credentials/README.md) | 凭据引用 seam + 环境变量优先于 `.env` 的提供方 | 产品:稳定 API |
+| [`credentials/`](credentials/README.md) | 凭据引用/记录 seam + 环境变量优先于 `.env` 的提供方 + 授权 flow | 产品:稳定 API |
 | [`storage/`](storage/README.md) | 非会话存储中枢 + 后端 + 领域形式 | 产品:稳定 API |
 | [`storage/`](storage/README.md) | 非会话存储中枢 + 后端 + 领域形式 | 产品:稳定 API |
 | [`workspace/`](workspace/README.md) | Workspace 实体 | 产品:稳定 API |
 | [`workspace/`](workspace/README.md) | Workspace 实体 | 产品:稳定 API |
 | [`sdk/`](sdk/README.md) | 进程外运行时 SDK:JSON-RPC 协议、TypeScript 客户端和服务器插件 | 产品:稳定 API |
 | [`sdk/`](sdk/README.md) | 进程外运行时 SDK:JSON-RPC 协议、TypeScript 客户端和服务器插件 | 产品:稳定 API |

+ 2 - 2
packages/credentials/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/credentials/README.md
 #   pnpm run verify-translation-pairing --write packages/credentials/README.md
-README.md: 75e3941ff6421ca7e41a71ab2bfe8b30209ce598
-README.zh.md: d7c958689fdffc82830a82b04e2a06802bc81634
+README.md: 032f0f38319e276b613241ba89a956a341eaa09c
+README.zh.md: 21e66ef6c8375ef1e4834b05f30901b0e38aebad

+ 5 - 4
packages/credentials/README.md

@@ -1,14 +1,15 @@
-# credentials/ — credential references
+# credentials/ — credentials and authorization
 
 
 English | [中文](README.zh.md)
 English | [中文](README.zh.md)
 
 
-The credential capability family separates reference resolution from its provider:
+The credential capability family separates reference resolution from its provider, and separates both from obtaining a credential that has to be asked for:
 
 
 | Package | Role | ctx key |
 | Package | Role | ctx key |
 |---|---|---|
 |---|---|---|
-| [`credentials/`](credentials/README.md) | Credential-reference seam | `ctx.credentials` |
+| [`credentials/`](credentials/README.md) | Credential-reference and credential-record seam | `ctx.credentials` |
 | [`credentials-local/`](credentials-local/README.md) | Environment and local-file provider | registers `ctx.credentials` |
 | [`credentials-local/`](credentials-local/README.md) | Environment and local-file provider | registers `ctx.credentials` |
+| [`authorization/`](authorization/README.md) | Plugin-owned flows that obtain a credential by asking a human | `ctx.authorization` |
 
 
-Configuration carries references, not secret values. Consumers resolve those references at their operation boundary; the child READMEs own mutation, precedence, and storage semantics.
+Configuration carries references, not secret values. Consumers resolve those references at their operation boundary; the child READMEs own mutation, precedence, and storage semantics. An authorization flow writes a credential record and is keyed by it, so the two seams meet at the record and nowhere else.
 
 
 The subsystem reference — `CredentialRef`, per-operation resolution, UI-safe `CredentialInfo`, provider layers — is [docs/subsystems/credentials.md](../../docs/subsystems/credentials.md).
 The subsystem reference — `CredentialRef`, per-operation resolution, UI-safe `CredentialInfo`, provider layers — is [docs/subsystems/credentials.md](../../docs/subsystems/credentials.md).

+ 5 - 4
packages/credentials/README.zh.md

@@ -1,14 +1,15 @@
-# credentials/:凭据引用
+# credentials/:凭据与授权
 
 
 [English](README.md) | 中文
 [English](README.md) | 中文
 
 
-凭据能力家族将引用解析与提供方分离:
+凭据能力家族将引用解析与提供方分离,并把二者与"必须开口去要才能拿到的凭据"再分开
 
 
 | 包 | 角色 | ctx 键 |
 | 包 | 角色 | ctx 键 |
 |---|---|---|
 |---|---|---|
-| [`credentials/`](credentials/README.md) | 凭据引用 seam | `ctx.credentials` |
+| [`credentials/`](credentials/README.md) | 凭据引用与凭据记录 seam | `ctx.credentials` |
 | [`credentials-local/`](credentials-local/README.md) | 环境与本地文件提供方 | 注册 `ctx.credentials` |
 | [`credentials-local/`](credentials-local/README.md) | 环境与本地文件提供方 | 注册 `ctx.credentials` |
+| [`authorization/`](authorization/README.md) | 由插件拥有、通过询问人来取得凭据的 flow | `ctx.authorization` |
 
 
-配置携带引用而非机密值。消费方在其操作边界解析这些引用;变更、优先级与存储语义由子级 README 负责。
+配置携带引用而非机密值。消费方在其操作边界解析这些引用;变更、优先级与存储语义由子级 README 负责。授权 flow 写入一条凭据记录并以它为键,因此两个 seam 只在记录处相交,别无其他接触面。
 
 
 子系统参考——`CredentialRef`、按操作解析、对 UI 安全的 `CredentialInfo`、提供方层——见 [docs/subsystems/credentials.md](../../docs/subsystems/credentials.md)。
 子系统参考——`CredentialRef`、按操作解析、对 UI 安全的 `CredentialInfo`、提供方层——见 [docs/subsystems/credentials.md](../../docs/subsystems/credentials.md)。

+ 6 - 0
packages/credentials/authorization/README.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write packages/credentials/authorization/README.md
+README.md: f73d2231456d0873cef7ea047fe537629a7552e0
+README.zh.md: ecceaa4f90a971deebe89e8dd6e51157aa0072a0

+ 74 - 0
packages/credentials/authorization/README.md

@@ -0,0 +1,74 @@
+# dsh-authorization
+
+English | [中文](README.zh.md)
+
+Authorization Service Definition (`ctx.authorization`). Some credentials cannot be configured, only obtained: getting one means a conversation with a human — open this page, paste that code, pick an account. This seam owns that conversation and the lifecycle around it, and never the protocol.
+
+**A flow is a plugin's knowledge of how to get its own credential.** It is registered under the [`CredentialKey`](../credentials/README.md#two-key-spaces-two-questions) it writes, so a flow says which record it produces and, through that key's scope, which plugin answers for the format inside it. A second authorization protocol arrives as another flow, not as another seam.
+
+**The flow owns the write.** `run()` resolving means the record is already committed through `ctx.credentials`; the seam confirms it and refuses a flow that resolved without one. Committing inside the flow is what lets a library that persists through its own store adapter stay the single writer instead of being copied back out and written twice.
+
+**The interaction travels with the request, not a registry.** Whoever starts an authorization is the one who can talk to the human about it, so prompts reach exactly the surface that asked and a headless caller supplies an interaction that declines. There is no ambient provider to be absent, and no question about which of two open pages a prompt belongs to.
+
+## Surface
+
+```ts
+import type { Context } from '@deepseek-ai/cordis'
+import type { AuthorizationSession } from '@deepseek-ai/dsh-authorization'
+import { credentialKey } from '@deepseek-ai/dsh-credentials'
+
+declare const ctx: Context
+declare const exchange: (signal: AbortSignal) => Promise<void>
+
+const key = credentialKey('llm-pi-ai', 'openai-codex')
+
+const dispose = ctx.authorization.registerFlow({
+  key,
+  label: 'ChatGPT (Codex)',
+  methods: [{ id: 'oauth', label: 'Sign in with ChatGPT' }],
+  async run(session: AuthorizationSession) {
+    session.notify({ message: 'Continue in your browser', url: 'https://auth.example/start' })
+    const code = await session.prompt({ kind: 'text', message: 'Paste the code' })
+    // Commits the record through ctx.credentials before resolving.
+    await exchange(session.signal)
+    void code
+  },
+})
+
+ctx.authorization.list()                    // [{ key, label, methods, inFlight }]
+ctx.authorization.describe(key)             // the same entry, or undefined
+await ctx.authorization.begin({             // { status: 'authorized' | 'cancelled' }
+  key,
+  interaction: { notify: () => {}, prompt: () => Promise.reject(new Error('headless')) },
+})
+ctx.authorization.cancel(key)               // withdraw whatever is running for the key
+dispose()
+```
+
+One attempt per key at a time. A second caller is refused with `ALREADY_IN_FLIGHT` rather than joined, because the two would be prompting different humans through one flow and the second would be answering questions the first was asked. `inFlight` is on the entry so a surface renders the button disabled instead of discovering this by error.
+
+`cancel(key)` exists beside the request's own signal because a request/response transport answers a Cancel button on a second call, holding no handle on the first one's signal. A flow whose registration is disposed mid-attempt is withdrawn the same way: its runner belongs to a plugin that is going away.
+
+An attempt whose caller has already withdrawn never claims the key and never starts the flow — relying on each flow to check its signal before the first await would let one that does not hang holding the key. Validation still runs first, so a caller naming a key or method that does not exist hears about it whether or not it also gave up.
+
+`authorization/settled (key, settlement)` fires after the key is released, for every terminal outcome. `settlement` adds `failed` to the two statuses `begin()` can return: a failure reaches its own caller as a thrown error, so the event stream is the only place a watcher that did not start the attempt can tell a refusal from a breakage.
+
+## The interaction vocabulary
+
+A notice is one-way and never carries a secret: a message, optionally the page the human must open and the code they must enter there. A prompt is a question the flow cannot answer — `text`, `secret`, or `select` — and `secret` differs from `text` only in presentation. A prompt carries its own `signal` so a flow that races a typed code against a browser callback can withdraw the losing question while the attempt continues; the request's signal withdraws the whole attempt instead.
+
+The vocabulary is deliberately smaller than any one provider's: it describes what a surface must render, so a surface that renders one flow renders all of them.
+
+## Model Experience
+
+None, as authorization is a configuration-time conversation with a human and no flow, notice, or prompt reaches a model request.
+
+#### KV Cache effect
+
+No invalidation; no authorization state enters a request prefix.
+
+## Known Limitations and Deferred Work
+
+- **No flow is resumable** — an attempt lives in the process that started it, so a browser reload during a login abandons it and the human starts over. Durable attempts need a store this seam does not have.
+- **Nothing revokes** — signing out is `ctx.credentials.deleteRecord(key)`, which forgets the local record without telling the issuer. A provider that needs a server-side revoke has no place to declare it yet.
+- **A key with no flow is inert** — the seam reports what is registered, so a record left by an uninstalled plugin can be deleted but not re-authorized. Recognizing that orphan is the caller's join, as it is for [`listRecords()`](../credentials/README.md#surface).

+ 74 - 0
packages/credentials/authorization/README.zh.md

@@ -0,0 +1,74 @@
+# dsh-authorization
+
+[English](README.md) | 中文
+
+授权 Service Definition(`ctx.authorization`)。有些凭据无法配置,只能获取:拿到它意味着与人对话——打开这个页面、粘贴那个码、选一个账号。本 seam 拥有这段对话及其生命周期,但从不拥有协议本身。
+
+**flow 是某个插件"如何取得自己那份凭据"的知识。** 它以自己写入的 [`CredentialKey`](../credentials/README.md#two-key-spaces-two-questions) 注册,因此 flow 声明了自己产出哪条记录,并通过该键的 scope 声明由哪个插件为记录内部的格式负责。第二种授权协议以另一个 flow 的形式到来,而不是另一个 seam。
+
+**写入由 flow 拥有。** `run()` 返回即表示记录已经通过 `ctx.credentials` 提交;seam 随后核实,并拒绝那些返回时没留下记录的 flow。让提交发生在 flow 内部,才能使一个通过自有 store 适配器持久化的库保持为唯一写入方,而不是把凭据复制出来再写第二遍。
+
+**交互随请求传入,而非注册表。** 发起授权的一方才是能与人对话的一方,因此提示恰好抵达发问的那个界面,无头调用方则传入一个直接拒绝的交互实现。这样既不存在"环境提供方缺席"的问题,也不会出现某个提示该归两个已打开页面中哪一个的疑问。
+
+## 接口
+
+```ts
+import type { Context } from '@deepseek-ai/cordis'
+import type { AuthorizationSession } from '@deepseek-ai/dsh-authorization'
+import { credentialKey } from '@deepseek-ai/dsh-credentials'
+
+declare const ctx: Context
+declare const exchange: (signal: AbortSignal) => Promise<void>
+
+const key = credentialKey('llm-pi-ai', 'openai-codex')
+
+const dispose = ctx.authorization.registerFlow({
+  key,
+  label: 'ChatGPT (Codex)',
+  methods: [{ id: 'oauth', label: 'Sign in with ChatGPT' }],
+  async run(session: AuthorizationSession) {
+    session.notify({ message: 'Continue in your browser', url: 'https://auth.example/start' })
+    const code = await session.prompt({ kind: 'text', message: 'Paste the code' })
+    // Commits the record through ctx.credentials before resolving.
+    await exchange(session.signal)
+    void code
+  },
+})
+
+ctx.authorization.list()                    // [{ key, label, methods, inFlight }]
+ctx.authorization.describe(key)             // the same entry, or undefined
+await ctx.authorization.begin({             // { status: 'authorized' | 'cancelled' }
+  key,
+  interaction: { notify: () => {}, prompt: () => Promise.reject(new Error('headless')) },
+})
+ctx.authorization.cancel(key)               // withdraw whatever is running for the key
+dispose()
+```
+
+同一个键同时只允许一次尝试。第二个调用方会收到 `ALREADY_IN_FLIGHT` 拒绝而不是被并入:否则两者会通过同一个 flow 向不同的人发问,而第二个人回答的是问给第一个人的问题。`inFlight` 放在 entry 上,界面据此把按钮渲染为禁用,而不是靠报错才发现。
+
+`cancel(key)` 与请求自带的 signal 并存,是因为请求/响应式传输要用第二次调用来响应"取消"按钮,而它拿不到第一次调用的 signal。注册在尝试进行中被 dispose 的 flow 也以同样方式撤销:它的执行体属于一个正在离开的插件。
+
+调用方在发起前就已撤销的尝试,既不占用该键也不启动 flow——若指望每个 flow 都在首个 await 之前检查自己的 signal,那么没有检查的那个就会占着键一直挂起。校验仍然先执行,因此调用方给出的键或方法不存在时,无论它是否已经放弃都会收到报错。
+
+`authorization/settled (key, settlement)` 在键释放之后触发,覆盖每一种终态。`settlement` 在 `begin()` 能返回的两种状态之外增加了 `failed`:失败以抛出的错误抵达其调用方,因此事件流是未发起该尝试的旁观者唯一能区分"被拒绝"与"出故障"的地方。
+
+## 交互词汇
+
+notice 是单向的,且从不携带机密:一条消息,以及可选的"人需要打开的页面"和"需要在该页面输入的码"。prompt 是 flow 无法自答的问题——`text`、`secret` 或 `select`——其中 `secret` 与 `text` 的差别仅在呈现方式。prompt 自带 `signal`,使得一个让手输码与浏览器回调赛跑的 flow 可以在尝试继续的同时撤下落败的那个问题;撤销整次尝试则用请求的 signal。
+
+这套词汇刻意小于任何单个 provider 的词汇:它描述的是界面必须渲染什么,因此能渲染一个 flow 的界面就能渲染全部 flow。
+
+## Model Experience
+
+无,因为授权是配置期与人的对话,flow、notice 与 prompt 都不会抵达模型请求。
+
+#### KV Cache effect
+
+不失效;任何授权状态都不会进入请求前缀。
+
+## Known Limitations and Deferred Work
+
+- **flow 不可恢复** —— 一次尝试只存活于发起它的进程中,因此登录途中刷新浏览器会丢弃它,人需要重来。可持久的尝试需要一个本 seam 并不具备的存储。
+- **没有吊销** —— 登出即 `ctx.credentials.deleteRecord(key)`,它只遗忘本地记录而不通知签发方。需要服务端吊销的 provider 目前无处声明这一点。
+- **没有 flow 的键是惰性的** —— seam 只报告已注册的内容,因此被卸载插件遗留的记录可以删除但无法重新授权。识别这种孤儿记录由调用方自行 join,与 [`listRecords()`](../credentials/README.md#surface) 的情况相同。

+ 51 - 0
packages/credentials/authorization/package.json

@@ -0,0 +1,51 @@
+{
+  "name": "@deepseek-ai/dsh-authorization",
+  "description": "Authorization seam (ctx.authorization): plugin-owned flows that obtain a credential through a conversation with the human",
+  "version": "0.0.1-rc.5",
+  "publishConfig": {
+    "access": "restricted"
+  },
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
+    "directory": "packages/credentials/authorization"
+  },
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./invariant": {
+      "types": "./lib/types/invariant.d.ts",
+      "default": "./lib/invariant.js"
+    },
+    "./types": {
+      "types": "./lib/types/types.d.ts",
+      "default": "./lib/types/types.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "files": [
+    "lib/index.js",
+    "lib/invariant.js",
+    "lib/types/**/*.js",
+    "lib/types/**/*.d.ts"
+  ],
+  "license": "MIT",
+  "peerDependencies": {
+    "@deepseek-ai/dsh-credentials": "workspace:^",
+    "@deepseek-ai/dsh-invariants": "workspace:^",
+    "@deepseek-ai/dsh-llm": "workspace:^",
+    "@deepseek-ai/cordis": "workspace:^"
+  },
+  "devDependencies": {
+    "@deepseek-ai/dsh-credentials": "workspace:^",
+    "@deepseek-ai/dsh-invariants": "workspace:^",
+    "@deepseek-ai/dsh-llm": "workspace:^",
+    "@deepseek-ai/cordis": "workspace:^"
+  }
+}

+ 324 - 0
packages/credentials/authorization/src/index.ts

@@ -0,0 +1,324 @@
+/**
+ * Service Definition for the authorization capability seam (`ctx.authorization`):
+ * obtaining a credential nobody can supply from configuration alone, because
+ * getting it requires a conversation with the human — open this page, paste
+ * that code, pick an account.
+ *
+ * The seam owns the conversation and the lifecycle; it never owns the protocol.
+ * A plugin that knows how to obtain its own credential registers a flow keyed
+ * by the `CredentialKey` that flow writes, and the flow talks to whatever
+ * surface started it through one neutral vocabulary of notices and prompts. So
+ * a second authorization protocol arrives as another flow rather than as
+ * another seam, and a surface that renders one flow renders all of them.
+ *
+ * ```ts
+ * const dispose = ctx.authorization.registerFlow({
+ *   key: credentialKey('llm-pi-ai', 'openai-codex'),
+ *   label: 'ChatGPT (Codex)',
+ *   methods: [{ id: 'oauth', label: 'Sign in with ChatGPT' }],
+ *   async run(session) {
+ *     session.notify({ message: 'Continue in your browser', url })
+ *     await commitThroughCredentials(await exchange(session.signal))
+ *   },
+ * })
+ * ```
+ *
+ * @module @deepseek-ai/dsh-authorization
+ */
+
+import { Context, Service } from '@deepseek-ai/cordis'
+import type { CredentialKey } from '@deepseek-ai/dsh-credentials'
+import { HarnessError } from '@deepseek-ai/dsh-llm'
+
+import type {
+  AuthorizationEntry, AuthorizationMethod, AuthorizationNotice, AuthorizationOutcome, AuthorizationPrompt,
+  AuthorizationSettlement,
+} from './types.ts'
+
+export type {
+  AuthorizationEntry, AuthorizationMethod, AuthorizationNotice, AuthorizationOutcome, AuthorizationPrompt,
+  AuthorizationPromptOption, AuthorizationSettlement, AuthorizationStatus,
+} from './types.ts'
+
+declare module '@deepseek-ai/cordis' {
+  interface Context {
+    authorization: AuthorizationService
+  }
+
+  interface Events {
+    /**
+     * One authorization attempt has finished and released its key. Fires for
+     * every terminal outcome, failures included, so a surface watching a key it
+     * did not start (a second browser tab) learns the attempt is over.
+     * @mode emit
+     * @param key - the credential record the finished attempt was authorizing.
+     * @param settlement - how it ended, including the `failed` case its caller sees as a thrown error.
+     */
+    'authorization/settled'(key: CredentialKey, settlement: AuthorizationSettlement): void
+  }
+}
+
+/** Stable error taxonomy for authorization failures. */
+export class AuthorizationError extends HarnessError {
+  constructor(message: string, code: string, options?: ErrorOptions) {
+    super(message, code, options)
+    this.name = 'AuthorizationError'
+  }
+}
+
+/**
+ * What a running flow is given to talk to the human. Every member is scoped to
+ * one attempt: the flow neither knows nor chooses which surface is listening.
+ */
+export interface AuthorizationSession {
+  /** The method id the caller picked, always one this flow declared. */
+  readonly method: string
+  /** Aborted when the caller withdraws or `cancel()` is called for this key. */
+  readonly signal: AbortSignal
+  /**
+   * Report progress, or tell the human what to do next. Fire-and-forget: a
+   * surface that cannot render a notice must not stall the flow.
+   * @param notice - the message, and any page or code it refers to.
+   */
+  notify(notice: AuthorizationNotice): void
+  /**
+   * Ask the human a question the flow cannot answer for itself.
+   * @param prompt - what to ask, and how it should be presented.
+   * @returns what the human typed, or the chosen option's id.
+   * @throws when the human declines, or the prompt's own signal withdraws it.
+   */
+  prompt(prompt: AuthorizationPrompt): Promise<string>
+}
+
+/**
+ * A plugin's knowledge of how to obtain one credential. The flow owns the
+ * write: `run()` resolving means the record for `key` is committed through
+ * `ctx.credentials`, which the seam then confirms before reporting success.
+ * Committing inside the flow is what lets a library that persists through its
+ * own store adapter (pi-ai's `Models.login()`) stay the single writer instead
+ * of being copied back out and written twice.
+ */
+export interface AuthorizationFlow {
+  /** The credential record this flow writes. Its scope names the owning plugin. */
+  readonly key: CredentialKey
+  /** User-facing name of what is being authorized. */
+  readonly label: string
+  /**
+   * The methods offered, most preferred first; a caller naming none gets the
+   * first. Typed non-empty because a flow with nothing to run is a flow that
+   * cannot be begun, and the type says so at the one place flows are written.
+   */
+  readonly methods: readonly [AuthorizationMethod, ...AuthorizationMethod[]]
+  /**
+   * Run one attempt to obtain and commit the credential.
+   * @param session - the chosen method, the cancellation signal, and the interaction callbacks.
+   * @returns once the record is committed.
+   * @throws when the attempt fails or the human declines.
+   */
+  run(session: AuthorizationSession): Promise<void>
+}
+
+/**
+ * The surface half of one attempt. Supplied with the request rather than
+ * registered, because the caller that starts an authorization is the one that
+ * can talk to the human about it: prompts reach exactly the page that asked,
+ * and a headless caller supplies an interaction that declines.
+ */
+export interface AuthorizationInteraction {
+  /**
+   * Render a notice from the running flow.
+   * @param notice - the message, and any page or code it refers to.
+   */
+  notify(notice: AuthorizationNotice): void
+  /**
+   * Put a question to the human and wait.
+   * @param prompt - what to ask, and how it should be presented.
+   * @returns the typed text, or the chosen option's id.
+   * @throws when the human declines or the prompt is withdrawn.
+   */
+  prompt(prompt: AuthorizationPrompt): Promise<string>
+}
+
+/** One request to authorize a key. */
+export interface AuthorizationRequest {
+  /** The credential record to authorize; a flow must be registered for it. */
+  key: CredentialKey
+  /** Which of the flow's methods to run. Defaults to the flow's first. */
+  method?: string
+  /** The surface that will render this attempt's notices and prompts. */
+  interaction: AuthorizationInteraction
+  /** Withdraws the whole attempt. */
+  signal?: AbortSignal
+}
+
+/** One attempt in flight, with the handle that withdraws it. */
+interface InFlight {
+  readonly controller: AbortController
+}
+
+/**
+ * `ctx.authorization`: a registry of credential-obtaining flows, one attempt at
+ * a time per key.
+ */
+export class AuthorizationService extends Service {
+  /** The commit this seam confirms is a credential-record write, so the store is required, not optional. */
+  static inject = ['credentials']
+
+  private readonly flows = new Map<CredentialKey, AuthorizationFlow>()
+  private readonly running = new Map<CredentialKey, InFlight>()
+
+  constructor(ctx: Context) {
+    super(ctx, 'authorization')
+  }
+
+  /**
+   * Offer a way to obtain one credential. One flow per key: two plugins
+   * claiming the same key would each write a record in their own format, and
+   * whichever ran last would leave the other reading a payload it cannot parse.
+   *
+   * @param flow - the key it writes, its label, its methods, and its runner.
+   * @returns Disposer that withdraws this flow.
+   * @throws {AuthorizationError} code `DUPLICATE_FLOW` when the key is already claimed.
+   */
+  registerFlow(flow: AuthorizationFlow): () => void {
+    const dispose = this.ctx.effect(function* (this: AuthorizationService) {
+      if (this.flows.has(flow.key)) {
+        throw new AuthorizationError(
+          `an authorization flow for "${flow.key}" is already registered`, 'DUPLICATE_FLOW')
+      }
+      this.flows.set(flow.key, flow)
+      yield () => {
+        this.flows.delete(flow.key)
+        // A flow leaving mid-attempt takes its attempt with it: the runner
+        // belongs to a plugin that is going away, so letting it keep prompting
+        // would outlive the fiber that can answer for it.
+        this.running.get(flow.key)?.controller.abort()
+      }
+    }.bind(this), 'authorization.registerFlow()')
+    return () => void dispose()
+  }
+
+  /**
+   * Every registered flow, for a surface listing what can be authorized.
+   * @returns one entry per flow, in registration order.
+   */
+  list(): readonly AuthorizationEntry[] {
+    return [...this.flows.values()].map(flow => this.entry(flow))
+  }
+
+  /**
+   * One registered flow.
+   * @param key - the credential record to ask about.
+   * @returns the entry, or undefined when no flow claims that key.
+   */
+  describe(key: CredentialKey): AuthorizationEntry | undefined {
+    const flow = this.flows.get(key)
+    return flow === undefined ? undefined : this.entry(flow)
+  }
+
+  /** The public view of one registered flow. */
+  private entry(flow: AuthorizationFlow): AuthorizationEntry {
+    return {
+      key: flow.key,
+      label: flow.label,
+      methods: flow.methods,
+      inFlight: this.running.has(flow.key),
+    }
+  }
+
+  /**
+   * Withdraw the attempt running for a key, if any. Separate from the
+   * request's own signal because a request/response transport answers a Cancel
+   * button on a second call, with no handle on the first one's signal.
+   * @param key - the credential record whose attempt should stop.
+   */
+  cancel(key: CredentialKey): void {
+    this.running.get(key)?.controller.abort()
+  }
+
+  /**
+   * Run one attempt to authorize a key, and report how it ended.
+   *
+   * One attempt per key at a time. A second caller is refused rather than
+   * joined: the two would be prompting different humans through the same flow,
+   * and the second would answer questions the first was asked.
+   *
+   * @param request - the key, the method, the surface, and the cancel signal.
+   * @returns `authorized` once the flow's record is committed and observed,
+   *   or `cancelled` when the human or the caller withdrew.
+   * @throws {AuthorizationError} code `NO_FLOW` when nothing claims the key,
+   *   `UNKNOWN_METHOD` when the named method is not one the flow offers,
+   *   `ALREADY_IN_FLIGHT` when an attempt is already running for the key, or
+   *   `NOT_COMMITTED` when the flow resolved without leaving a record behind.
+   */
+  async begin(request: AuthorizationRequest): Promise<AuthorizationOutcome> {
+    const { key } = request
+    const flow = this.flows.get(key)
+    if (flow === undefined) {
+      throw new AuthorizationError(`no authorization flow is registered for "${key}"`, 'NO_FLOW')
+    }
+    const method = request.method ?? flow.methods[0].id
+    if (!flow.methods.some(candidate => candidate.id === method)) {
+      throw new AuthorizationError(
+        `authorization flow for "${key}" offers no method "${method}"`, 'UNKNOWN_METHOD')
+    }
+    if (this.running.has(key)) {
+      throw new AuthorizationError(
+        `an authorization attempt for "${key}" is already running`, 'ALREADY_IN_FLIGHT')
+    }
+    // Withdrawn before it began: never claim the slot and never run the flow.
+    // Handing an aborted signal to `run()` would rely on every flow checking it
+    // before its first await, and one that does not would hang holding the key.
+    // Validation still runs first, so a caller naming a key or method that does
+    // not exist hears about it whether or not it also gave up.
+    if (request.signal?.aborted === true) return { status: 'cancelled' }
+    const controller = new AbortController()
+    const withdraw = (): void => { controller.abort(request.signal?.reason) }
+    request.signal?.addEventListener('abort', withdraw, { once: true })
+    this.running.set(key, { controller })
+    let settlement: AuthorizationSettlement = 'failed'
+    try {
+      const outcome = await this.attempt(flow, method, controller.signal, request.interaction)
+      settlement = outcome.status
+      return outcome
+    } finally {
+      request.signal?.removeEventListener('abort', withdraw)
+      this.running.delete(key)
+      // After the slot is released, so a listener that reacts by starting the
+      // next attempt is not refused by the one that just finished.
+      this.ctx.emit('authorization/settled', key, settlement)
+    }
+  }
+
+  /** Run the flow, then hold it to its half of the commit contract. */
+  private async attempt(
+    flow: AuthorizationFlow,
+    method: string,
+    signal: AbortSignal,
+    interaction: AuthorizationInteraction,
+  ): Promise<AuthorizationOutcome> {
+    try {
+      await flow.run({
+        method,
+        signal,
+        notify: (notice) => { interaction.notify(notice) },
+        prompt: prompt => interaction.prompt(prompt),
+      })
+    } catch (error) {
+      // A withdrawn attempt is an outcome, not a failure: the human said no, or
+      // closed the page. Anything else is the flow failing and belongs to the
+      // caller, cause chain intact.
+      if (signal.aborted) return { status: 'cancelled' }
+      throw error
+    }
+    const stored = await this.ctx.credentials.describeRecord(flow.key)
+    if (!stored.configured) {
+      throw new AuthorizationError(
+        `authorization flow for "${flow.key}" resolved without committing a credential record`,
+        'NOT_COMMITTED')
+    }
+    return { status: 'authorized' }
+  }
+}
+
+export default AuthorizationService

+ 45 - 0
packages/credentials/authorization/src/invariant.ts

@@ -0,0 +1,45 @@
+/**
+ * Package-owned invariant companion for `@deepseek-ai/dsh-authorization`.
+ * @module @deepseek-ai/dsh-authorization/invariant
+ */
+
+import type { Context } from '@deepseek-ai/cordis'
+import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
+
+const PACKAGE_NAME = '@deepseek-ai/dsh-authorization'
+
+/** Cordis companion plugin name. */
+export const name = 'authorization-invariant'
+/** Service required before the companion can reserve package ownership. */
+export const inject = ['invariants']
+
+/**
+ * Install the single-flight release contract: `authorization/settled` names a
+ * finished attempt, and the seam admits one attempt per key, so the key must
+ * already be free when the event fires. A slot still held at settlement is
+ * unrecoverable — every later `begin()` for that key is refused as
+ * `ALREADY_IN_FLIGHT` until the process restarts — and it is invisible from the
+ * outside, because a wedged key looks exactly like a busy one.
+ */
+const install: InvariantInstaller = (ctx: Context, fail: InvariantFailure) => {
+  ctx.on('authorization/settled', (key) => {
+    const authorization = ctx.get('authorization')
+    if (authorization === undefined) {
+      fail(`authorization/settled for "${key}" emitted without a live authorization service`)
+      return
+    }
+    // A flow withdrawn during its own attempt settles with nothing left to
+    // describe, which is the disposer's documented behavior rather than a leak.
+    if (authorization.describe(key)?.inFlight === true) {
+      fail(`authorization/settled for "${key}" left the key in flight, wedging every later attempt`)
+    }
+  })
+}
+
+/**
+ * Register this package's invariant companion.
+ * @param ctx - Cordis context carrying the invariant service.
+ * @returns the installed registration's disposer after setup succeeds.
+ */
+export const apply = (ctx: Context): Promise<() => void> =>
+  Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))

+ 91 - 0
packages/credentials/authorization/src/types.ts

@@ -0,0 +1,91 @@
+/**
+ * Wire-safe authorization types, free of cordis/service imports so browser type
+ * chains (apiproxy api → client) can consume them without loading this
+ * package's Context augmentation.
+ * @module @deepseek-ai/dsh-authorization/types
+ */
+
+import type { CredentialKey } from '@deepseek-ai/dsh-credentials/types'
+
+/** One way a flow can obtain its credential, named by the flow that offers it. */
+export interface AuthorizationMethod {
+  /** Flow-owned identifier, echoed back when a caller picks this method. */
+  id: string
+  /** User-facing label for a picker. */
+  label: string
+}
+
+/** A running flow's report to whoever is watching it. Never carries a secret. */
+export interface AuthorizationNotice {
+  /** What is happening, or what the human must do next. */
+  message: string
+  /** A page the human must open to continue. */
+  url?: string
+  /** A short code the human must enter on that page. */
+  code?: string
+}
+
+/** One choice offered by a `select` prompt. */
+export interface AuthorizationPromptOption {
+  /** Value returned when this option is chosen. */
+  id: string
+  /** User-facing label. */
+  label: string
+  /** Optional extra context rendered by capable surfaces. */
+  description?: string
+}
+
+/**
+ * A question a flow must have answered before it can continue. `secret` differs
+ * from `text` only in presentation — a surface masks it and keeps it out of
+ * logs — and `select` answers with the chosen option's `id`.
+ */
+export type AuthorizationPrompt = {
+  /**
+   * Withdraws this prompt alone, leaving the flow running. A flow that races a
+   * typed code against a browser callback aborts the losing prompt here; the
+   * whole authorization is cancelled through the request's signal instead.
+   */
+  signal?: AbortSignal
+} & ({
+  kind: 'text'
+  message: string
+  placeholder?: string
+} | {
+  kind: 'secret'
+  message: string
+  placeholder?: string
+} | {
+  kind: 'select'
+  message: string
+  options: readonly AuthorizationPromptOption[]
+})
+
+/** How one authorization attempt ended, as its own caller sees it. */
+export type AuthorizationStatus = 'authorized' | 'cancelled'
+
+/**
+ * How one attempt ended, as an onlooker sees it. A failure reaches its caller
+ * as a thrown error rather than an outcome, so `failed` exists only here — on
+ * the event stream, where a watcher that did not start the attempt has no
+ * other way to tell a refusal from a breakage.
+ */
+export type AuthorizationSettlement = AuthorizationStatus | 'failed'
+
+/** The result of one `begin()` attempt. */
+export interface AuthorizationOutcome {
+  /** `authorized` once the record is committed and observed; `cancelled` when the human or caller withdrew. */
+  status: AuthorizationStatus
+}
+
+/** A registered flow as a surface sees it: what it authorizes and whether it is busy. */
+export interface AuthorizationEntry {
+  /** The credential record this flow writes. */
+  key: CredentialKey
+  /** User-facing name of what is being authorized. */
+  label: string
+  /** The methods this flow offers, most preferred first. */
+  methods: readonly AuthorizationMethod[]
+  /** Whether an attempt for this key is running right now. */
+  inFlight: boolean
+}

+ 283 - 0
packages/credentials/authorization/tests/authorization.spec.ts

@@ -0,0 +1,283 @@
+import { describe, expect, it, vi } from 'vitest'
+import { Context } from '@deepseek-ai/cordis'
+import { credentialKey } from '@deepseek-ai/dsh-credentials'
+import AuthorizationService, {
+  type AuthorizationFlow,
+  type AuthorizationInteraction,
+  type AuthorizationSession,
+} from '@deepseek-ai/dsh-authorization'
+import { MemoryCredentials } from './memory.ts'
+
+const KEY = credentialKey('llm-pi-ai', 'openai-codex')
+const OTHER = credentialKey('llm-pi-ai', 'anthropic')
+
+/** A context with the record store the seam confirms commits against. */
+async function harness(): Promise<Context> {
+  const ctx = new Context()
+  await ctx.plugin(MemoryCredentials)
+  await ctx.plugin(AuthorizationService)
+  return ctx
+}
+
+/** An interaction that answers every prompt with the same string. */
+function surface(answer = 'typed'): AuthorizationInteraction & {
+  notices: unknown[]
+  prompts: unknown[]
+} {
+  const notices: unknown[] = []
+  const prompts: unknown[] = []
+  return {
+    notices,
+    prompts,
+    notify: (notice) => { notices.push(notice) },
+    prompt: (prompt) => {
+      prompts.push(prompt)
+      return Promise.resolve(answer)
+    },
+  }
+}
+
+/** A flow that commits `key` through the record store and then resolves. */
+function committingFlow(
+  ctx: Context,
+  key = KEY,
+  run?: (session: AuthorizationSession) => Promise<void>,
+): AuthorizationFlow {
+  return {
+    key,
+    label: 'ChatGPT (Codex)',
+    methods: [{ id: 'oauth', label: 'Sign in with ChatGPT' }, { id: 'api-key', label: 'Paste a key' }],
+    async run(session) {
+      await run?.(session)
+      await ctx.credentials.modifyRecord(key, () =>
+        Promise.resolve({ kind: 'grant', payload: { token: 'granted' } }))
+    },
+  }
+}
+
+describe('AuthorizationService registry', () => {
+  it('lists a registered flow and drops it when the registration is disposed', async () => {
+    const ctx = await harness()
+
+    const dispose = ctx.authorization.registerFlow(committingFlow(ctx))
+
+    expect(ctx.authorization.list()).toEqual([{
+      key: KEY,
+      label: 'ChatGPT (Codex)',
+      methods: [{ id: 'oauth', label: 'Sign in with ChatGPT' }, { id: 'api-key', label: 'Paste a key' }],
+      inFlight: false,
+    }])
+    expect(ctx.authorization.describe(KEY)?.label).toBe('ChatGPT (Codex)')
+    expect(ctx.authorization.describe(OTHER)).toBeUndefined()
+
+    dispose()
+
+    expect(ctx.authorization.list()).toEqual([])
+    expect(ctx.authorization.describe(KEY)).toBeUndefined()
+  })
+
+  it('refuses a second flow for the same key', async () => {
+    const ctx = await harness()
+    ctx.authorization.registerFlow(committingFlow(ctx))
+
+    expect(() => ctx.authorization.registerFlow(committingFlow(ctx)))
+      .toThrow(/already registered/)
+  })
+
+  it('withdraws an attempt still running when its flow leaves', async () => {
+    const ctx = await harness()
+    let started: (() => void) | undefined
+    const running = new Promise<void>((resolve) => {
+      started = resolve
+    })
+    const dispose = ctx.authorization.registerFlow(committingFlow(ctx, KEY, session =>
+      new Promise((_resolve, reject) => {
+        started?.()
+        session.signal.addEventListener('abort', () => { reject(new Error('withdrawn')) }, { once: true })
+      })))
+
+    const attempt = ctx.authorization.begin({ key: KEY, interaction: surface() })
+    await running
+    dispose()
+
+    await expect(attempt).resolves.toEqual({ status: 'cancelled' })
+  })
+})
+
+describe('AuthorizationService.begin', () => {
+  it('runs the flow, confirms the committed record, and reports the settlement', async () => {
+    const ctx = await harness()
+    ctx.authorization.registerFlow(committingFlow(ctx))
+    const settled = vi.fn()
+    ctx.on('authorization/settled', settled)
+
+    await expect(ctx.authorization.begin({ key: KEY, interaction: surface() }))
+      .resolves.toEqual({ status: 'authorized' })
+
+    expect(await ctx.credentials.readRecord(KEY)).toEqual({ kind: 'grant', payload: { token: 'granted' } })
+    expect(settled).toHaveBeenCalledWith(KEY, 'authorized')
+  })
+
+  it('runs the flow first method when the caller names none, and the named one when it does', async () => {
+    const ctx = await harness()
+    const seen: string[] = []
+    ctx.authorization.registerFlow(committingFlow(ctx, KEY, (session) => {
+      seen.push(session.method)
+      return Promise.resolve()
+    }))
+
+    await ctx.authorization.begin({ key: KEY, interaction: surface() })
+    await ctx.authorization.begin({ key: KEY, method: 'api-key', interaction: surface() })
+
+    expect(seen).toEqual(['oauth', 'api-key'])
+  })
+
+  it('carries notices and prompts between the flow and the calling surface', async () => {
+    const ctx = await harness()
+    const answers: string[] = []
+    ctx.authorization.registerFlow(committingFlow(ctx, KEY, async (session) => {
+      session.notify({ message: 'Continue in your browser', url: 'https://auth.example/start' })
+      answers.push(await session.prompt({ kind: 'text', message: 'Paste the code' }))
+    }))
+    const ui = surface('code-123')
+
+    await ctx.authorization.begin({ key: KEY, interaction: ui })
+
+    expect(ui.notices).toEqual([{ message: 'Continue in your browser', url: 'https://auth.example/start' }])
+    expect(ui.prompts).toEqual([{ kind: 'text', message: 'Paste the code' }])
+    expect(answers).toEqual(['code-123'])
+  })
+
+  it('refuses a key no flow claims', async () => {
+    const ctx = await harness()
+
+    await expect(ctx.authorization.begin({ key: KEY, interaction: surface() }))
+      .rejects.toThrow(/no authorization flow is registered/)
+  })
+
+  it('refuses a method the flow does not offer', async () => {
+    const ctx = await harness()
+    ctx.authorization.registerFlow(committingFlow(ctx))
+
+    await expect(ctx.authorization.begin({ key: KEY, method: 'device', interaction: surface() }))
+      .rejects.toThrow(/offers no method "device"/)
+  })
+
+  it('refuses a second attempt while one is running, and admits one after it settles', async () => {
+    const ctx = await harness()
+    // Only the first attempt blocks; the later ones must be free to complete,
+    // which is what shows the key was released rather than merely idle-looking.
+    const held = Promise.withResolvers<undefined>()
+    const started = Promise.withResolvers<undefined>()
+    let first = true
+    ctx.authorization.registerFlow(committingFlow(ctx, KEY, () => {
+      if (!first) return Promise.resolve()
+      first = false
+      started.resolve(undefined)
+      return held.promise
+    }))
+
+    const attempt = ctx.authorization.begin({ key: KEY, interaction: surface() })
+    await started.promise
+    expect(ctx.authorization.describe(KEY)?.inFlight).toBe(true)
+    await expect(ctx.authorization.begin({ key: KEY, interaction: surface() }))
+      .rejects.toThrow(/already running/)
+
+    held.resolve(undefined)
+    await expect(attempt).resolves.toEqual({ status: 'authorized' })
+    expect(ctx.authorization.describe(KEY)?.inFlight).toBe(false)
+    await expect(ctx.authorization.begin({ key: KEY, interaction: surface() }))
+      .resolves.toEqual({ status: 'authorized' })
+  })
+
+  it('never starts a flow whose caller withdrew before begin', async () => {
+    const ctx = await harness()
+    const ran = vi.fn()
+    const settled = vi.fn()
+    ctx.on('authorization/settled', settled)
+    ctx.authorization.registerFlow(committingFlow(ctx, KEY, () => {
+      ran()
+      return new Promise(() => {})
+    }))
+
+    await expect(ctx.authorization.begin({
+      key: KEY,
+      interaction: surface(),
+      signal: AbortSignal.abort(),
+    })).resolves.toEqual({ status: 'cancelled' })
+
+    expect(ran).not.toHaveBeenCalled()
+    // Nothing occupied the key, so nothing settled on it either.
+    expect(settled).not.toHaveBeenCalled()
+    expect(ctx.authorization.describe(KEY)?.inFlight).toBe(false)
+  })
+
+  it('still reports an unknown method to a caller that already withdrew', async () => {
+    const ctx = await harness()
+    ctx.authorization.registerFlow(committingFlow(ctx))
+
+    await expect(ctx.authorization.begin({
+      key: KEY,
+      method: 'device',
+      interaction: surface(),
+      signal: AbortSignal.abort(),
+    })).rejects.toThrow(/offers no method "device"/)
+  })
+
+  it('reports a caller that withdraws mid-flight as cancelled', async () => {
+    const ctx = await harness()
+    const controller = new AbortController()
+    ctx.authorization.registerFlow(committingFlow(ctx, KEY, session =>
+      new Promise((_resolve, reject) => {
+        session.signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
+        controller.abort()
+      })))
+
+    await expect(ctx.authorization.begin({ key: KEY, interaction: surface(), signal: controller.signal }))
+      .resolves.toEqual({ status: 'cancelled' })
+  })
+
+  it('withdraws a running attempt through cancel(), and ignores cancel() for an idle key', async () => {
+    const ctx = await harness()
+    const started = Promise.withResolvers<undefined>()
+    ctx.authorization.registerFlow(committingFlow(ctx, KEY, session =>
+      new Promise((_resolve, reject) => {
+        session.signal.addEventListener('abort', () => { reject(new Error('cancelled')) }, { once: true })
+        started.resolve(undefined)
+      })))
+    ctx.authorization.cancel(OTHER)
+
+    const attempt = ctx.authorization.begin({ key: KEY, interaction: surface() })
+    await started.promise
+    ctx.authorization.cancel(KEY)
+
+    await expect(attempt).resolves.toEqual({ status: 'cancelled' })
+  })
+
+  it('propagates a flow failure to its caller and settles the key as failed', async () => {
+    const ctx = await harness()
+    ctx.authorization.registerFlow(committingFlow(ctx, KEY, () =>
+      Promise.reject(new Error('the token endpoint said no'))))
+    const settled = vi.fn()
+    ctx.on('authorization/settled', settled)
+
+    await expect(ctx.authorization.begin({ key: KEY, interaction: surface() }))
+      .rejects.toThrow('the token endpoint said no')
+
+    expect(settled).toHaveBeenCalledWith(KEY, 'failed')
+    expect(ctx.authorization.describe(KEY)?.inFlight).toBe(false)
+  })
+
+  it('refuses a flow that resolves without committing its record', async () => {
+    const ctx = await harness()
+    ctx.authorization.registerFlow({
+      key: KEY,
+      label: 'Forgetful',
+      methods: [{ id: 'oauth', label: 'Sign in' }],
+      run: () => Promise.resolve(),
+    })
+
+    await expect(ctx.authorization.begin({ key: KEY, interaction: surface() }))
+      .rejects.toThrow(/resolved without committing a credential record/)
+  })
+})

+ 87 - 0
packages/credentials/authorization/tests/invariant.spec.ts

@@ -0,0 +1,87 @@
+import { describe, expect, it } from 'vitest'
+import { Context } from '@deepseek-ai/cordis'
+import { credentialKey } from '@deepseek-ai/dsh-credentials'
+import InvariantRegistry from '@deepseek-ai/dsh-invariants'
+import AuthorizationService from '@deepseek-ai/dsh-authorization'
+import * as AuthorizationInvariant from '../src/invariant.ts'
+import { MemoryCredentials } from './memory.ts'
+
+const KEY = credentialKey('llm-pi-ai', 'openai-codex')
+
+describe('authorization invariant companion', () => {
+  it('accepts an attempt that released its key before settling', async () => {
+    const ctx = new Context()
+    await ctx.plugin(InvariantRegistry)
+    await ctx.plugin(AuthorizationInvariant)
+    await ctx.plugin(MemoryCredentials)
+    await ctx.plugin(AuthorizationService)
+    ctx.authorization.registerFlow({
+      key: KEY,
+      label: 'ChatGPT (Codex)',
+      methods: [{ id: 'oauth', label: 'Sign in' }],
+      run: () => ctx.credentials
+        .modifyRecord(KEY, () => Promise.resolve({ kind: 'grant', payload: {} }))
+        .then(() => undefined),
+    })
+
+    await expect(ctx.authorization.begin({
+      key: KEY,
+      interaction: { notify: () => {}, prompt: () => Promise.reject(new Error('unused')) },
+    })).resolves.toEqual({ status: 'authorized' })
+  })
+
+  it('fails a settlement that left its key in flight', async () => {
+    const ctx = new Context()
+    await ctx.plugin(InvariantRegistry)
+    await ctx.plugin(AuthorizationInvariant)
+    await ctx.plugin(MemoryCredentials)
+    await ctx.plugin(AuthorizationService)
+    const started = Promise.withResolvers<undefined>()
+    ctx.authorization.registerFlow({
+      key: KEY,
+      label: 'ChatGPT (Codex)',
+      methods: [{ id: 'oauth', label: 'Sign in' }],
+      run: () => {
+        started.resolve(undefined)
+        return new Promise(() => {})
+      },
+    })
+    void ctx.authorization.begin({
+      key: KEY,
+      interaction: { notify: () => {}, prompt: () => Promise.reject(new Error('unused')) },
+    })
+    await started.promise
+
+    expect(() => { ctx.emit('authorization/settled', KEY, 'authorized') })
+      .toThrow(/left the key in flight/)
+  })
+
+  it('fails a settlement emitted without a live service', async () => {
+    const ctx = new Context()
+    await ctx.plugin(InvariantRegistry)
+    await ctx.plugin(AuthorizationInvariant)
+
+    expect(() => { ctx.emit('authorization/settled', KEY, 'cancelled') })
+      .toThrow(/without a live authorization service/)
+  })
+
+  it('accepts a settlement whose flow left during its own attempt', async () => {
+    const ctx = new Context()
+    await ctx.plugin(InvariantRegistry)
+    await ctx.plugin(AuthorizationInvariant)
+    await ctx.plugin(MemoryCredentials)
+    await ctx.plugin(AuthorizationService)
+
+    expect(() => { ctx.emit('authorization/settled', KEY, 'cancelled') }).not.toThrow()
+  })
+
+  it('reserves the package name against duplicate registration', async () => {
+    const ctx = new Context()
+    await ctx.plugin(InvariantRegistry)
+    await ctx.plugin(AuthorizationInvariant)
+
+    expect(() => {
+      ctx.invariants.register('@deepseek-ai/dsh-authorization', () => {})
+    }).toThrow(/already registered/)
+  })
+})

+ 67 - 0
packages/credentials/authorization/tests/memory.ts

@@ -0,0 +1,67 @@
+import { CredentialProvider } from '@deepseek-ai/dsh-credentials'
+import type {
+  CredentialInfo,
+  CredentialKey,
+  CredentialRecord,
+  CredentialRecordEntry,
+  CredentialRecordInfo,
+  CredentialRef,
+  ResolvedCredential,
+} from '@deepseek-ai/dsh-credentials'
+
+/**
+ * In-memory credentials provider for the authorization suite. Only the record
+ * half is exercised — the seam's whole interest in this service is whether a
+ * flow left a record behind — so the reference half answers "nothing stored".
+ */
+export class MemoryCredentials extends CredentialProvider {
+  private readonly records = new Map<CredentialKey, CredentialRecord>()
+
+  override resolve(_ref: CredentialRef): Promise<ResolvedCredential | undefined> {
+    return Promise.resolve(undefined)
+  }
+
+  override describe(_ref: CredentialRef): Promise<CredentialInfo> {
+    return Promise.resolve({ configured: false, writable: true })
+  }
+
+  override set(_ref: CredentialRef, _value: string): Promise<void> {
+    return Promise.resolve()
+  }
+
+  override unset(_ref: CredentialRef): Promise<void> {
+    return Promise.resolve()
+  }
+
+  override readRecord(key: CredentialKey): Promise<CredentialRecord | undefined> {
+    return Promise.resolve(this.records.get(key))
+  }
+
+  override describeRecord(key: CredentialKey): Promise<CredentialRecordInfo> {
+    const stored = this.records.get(key)
+    return Promise.resolve(stored === undefined
+      ? { configured: false, writable: true }
+      : { configured: true, kind: stored.kind, writable: true })
+  }
+
+  override listRecords(): Promise<readonly CredentialRecordEntry[]> {
+    return Promise.resolve([...this.records].map(([key, record]) => ({ key, kind: record.kind })))
+  }
+
+  override async modifyRecord(
+    key: CredentialKey,
+    mutate: (current: CredentialRecord | undefined) => Promise<CredentialRecord | undefined>,
+  ): Promise<CredentialRecord | undefined> {
+    const current = this.records.get(key)
+    const next = await mutate(current)
+    if (next === undefined) return current
+    this.records.set(key, next)
+    this.ctx.emit('credentials/record-updated', key)
+    return next
+  }
+
+  override deleteRecord(key: CredentialKey): Promise<void> {
+    if (this.records.delete(key)) this.ctx.emit('credentials/record-updated', key)
+    return Promise.resolve()
+  }
+}

+ 27 - 0
packages/credentials/authorization/tsconfig.json

@@ -0,0 +1,27 @@
+{
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types"
+  },
+  "include": [
+    "src"
+  ],
+  "references": [
+    {
+      "path": "../../../vendor/cosmokit"
+    },
+    {
+      "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../credentials"
+    },
+    {
+      "path": "../../llm/llm"
+    },
+    {
+      "path": "../../runtime-diagnostics/invariants"
+    }
+  ]
+}

+ 94 - 0
packages/extensions/tool-cordis/src/api-catalog.ts

@@ -457,6 +457,44 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
       },
       },
     ],
     ],
   },
   },
+  {
+    key: 'authorization',
+    summary: '`ctx.authorization`: a registry of credential-obtaining flows, one attempt at a time per key.',
+    description: '`ctx.authorization`: a registry of credential-obtaining flows, one attempt at a time per key.',
+    methods: [
+      {
+        signature: 'registerFlow(flow: AuthorizationFlow): () => void',
+        description: 'Offer a way to obtain one credential. One flow per key: two plugins claiming the same key would each write a record in their own format, and whichever ran last would leave the other reading a payload it cannot parse.',
+        parameters: [{ name: 'flow', description: 'the key it writes, its label, its methods, and its runner.' }],
+        returns: 'Disposer that withdraws this flow.',
+        throws: ['{AuthorizationError} code `DUPLICATE_FLOW` when the key is already claimed.'],
+      },
+      {
+        signature: 'list(): readonly AuthorizationEntry[]',
+        description: 'Every registered flow, for a surface listing what can be authorized.',
+        parameters: [],
+        returns: 'one entry per flow, in registration order.',
+      },
+      {
+        signature: 'describe(key: CredentialKey): AuthorizationEntry | undefined',
+        description: 'One registered flow.',
+        parameters: [{ name: 'key', description: 'the credential record to ask about.' }],
+        returns: 'the entry, or undefined when no flow claims that key.',
+      },
+      {
+        signature: 'cancel(key: CredentialKey): void',
+        description: 'Withdraw the attempt running for a key, if any. Separate from the request\'s own signal because a request/response transport answers a Cancel button on a second call, with no handle on the first one\'s signal.',
+        parameters: [{ name: 'key', description: 'the credential record whose attempt should stop.' }],
+      },
+      {
+        signature: 'async begin(request: AuthorizationRequest): Promise<AuthorizationOutcome>',
+        description: 'Run one attempt to authorize a key, and report how it ended.\n\nOne attempt per key at a time. A second caller is refused rather than joined: the two would be prompting different humans through the same flow, and the second would answer questions the first was asked.',
+        parameters: [{ name: 'request', description: 'the key, the method, the surface, and the cancel signal.' }],
+        returns: '`authorized` once the flow\'s record is committed and observed, or `cancelled` when the human or the caller withdrew.',
+        throws: ['{AuthorizationError} code `NO_FLOW` when nothing claims the key, `UNKNOWN_METHOD` when the named method is not one the flow offers, `ALREADY_IN_FLIGHT` when an attempt is already running for the key, or `NOT_COMMITTED` when the flow resolved without leaving a record behind.'],
+      },
+    ],
+  },
   {
   {
     key: 'clientModules',
     key: 'clientModules',
     summary: 'The web plugin table service: incremental `dsh.client` scan + wire composition + bundle route + index injection rows.',
     summary: 'The web plugin table service: incremental `dsh.client` scan + wire composition + bundle route + index injection rows.',
@@ -2446,6 +2484,14 @@ export const EVENT_API: readonly EventApiEntry[] = [
     description: 'Ask composed answerers for one decision. Return an outcome to claim the request or call `next()`; failure yields the fail-closed default. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.',
     description: 'Ask composed answerers for one decision. Return an outcome to claim the request or call `next()`; failure yields the fail-closed default. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.',
     parameters: [{ name: 'req', description: 'the pending decision (agent, tool identity, reason, signal).' }],
     parameters: [{ name: 'req', description: 'the pending decision (agent, tool identity, reason, signal).' }],
   },
   },
+  {
+    name: 'authorization/settled',
+    mode: 'emit',
+    signature: '\'authorization/settled\'(key: CredentialKey, settlement: AuthorizationSettlement): void',
+    summary: 'One authorization attempt has finished and released its key.',
+    description: 'One authorization attempt has finished and released its key. Fires for every terminal outcome, failures included, so a surface watching a key it did not start (a second browser tab) learns the attempt is over.',
+    parameters: [{ name: 'key', description: 'the credential record the finished attempt was authorizing.' }, { name: 'settlement', description: 'how it ended, including the `failed` case its caller sees as a thrown error.' }],
+  },
   {
   {
     name: 'commands/change',
     name: 'commands/change',
     mode: 'emit',
     mode: 'emit',
@@ -2902,6 +2948,54 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'AttachmentId',
     name: 'AttachmentId',
     declaration: 'export type AttachmentId = Branded<\'AttachmentId\'>;',
     declaration: 'export type AttachmentId = Branded<\'AttachmentId\'>;',
   },
   },
+  {
+    name: 'AuthorizationEntry',
+    declaration: 'export interface AuthorizationEntry {\n    key: CredentialKey;\n    label: string;\n    methods: readonly AuthorizationMethod[];\n    inFlight: boolean;\n}',
+  },
+  {
+    name: 'AuthorizationFlow',
+    declaration: 'export interface AuthorizationFlow {\n    readonly key: CredentialKey;\n    readonly label: string;\n    readonly methods: readonly [\n        AuthorizationMethod,\n        ...AuthorizationMethod[]\n    ];\n    run(session: AuthorizationSession): Promise<void>;\n}',
+  },
+  {
+    name: 'AuthorizationInteraction',
+    declaration: 'export interface AuthorizationInteraction {\n    notify(notice: AuthorizationNotice): void;\n    prompt(prompt: AuthorizationPrompt): Promise<string>;\n}',
+  },
+  {
+    name: 'AuthorizationMethod',
+    declaration: 'export interface AuthorizationMethod {\n    id: string;\n    label: string;\n}',
+  },
+  {
+    name: 'AuthorizationNotice',
+    declaration: 'export interface AuthorizationNotice {\n    message: string;\n    url?: string;\n    code?: string;\n}',
+  },
+  {
+    name: 'AuthorizationOutcome',
+    declaration: 'export interface AuthorizationOutcome {\n    status: AuthorizationStatus;\n}',
+  },
+  {
+    name: 'AuthorizationPrompt',
+    declaration: 'export type AuthorizationPrompt = {\n    signal?: AbortSignal;\n} & ({\n    kind: \'text\';\n    message: string;\n    placeholder?: string;\n} | {\n    kind: \'secret\';\n    message: string;\n    placeholder?: string;\n} | {\n    kind: \'select\';\n    message: string;\n    options: readonly AuthorizationPromptOption[];\n});',
+  },
+  {
+    name: 'AuthorizationPromptOption',
+    declaration: 'export interface AuthorizationPromptOption {\n    id: string;\n    label: string;\n    description?: string;\n}',
+  },
+  {
+    name: 'AuthorizationRequest',
+    declaration: 'export interface AuthorizationRequest {\n    key: CredentialKey;\n    method?: string;\n    interaction: AuthorizationInteraction;\n    signal?: AbortSignal;\n}',
+  },
+  {
+    name: 'AuthorizationSession',
+    declaration: 'export interface AuthorizationSession {\n    readonly method: string;\n    readonly signal: AbortSignal;\n    notify(notice: AuthorizationNotice): void;\n    prompt(prompt: AuthorizationPrompt): Promise<string>;\n}',
+  },
+  {
+    name: 'AuthorizationSettlement',
+    declaration: 'export type AuthorizationSettlement = AuthorizationStatus | \'failed\';',
+  },
+  {
+    name: 'AuthorizationStatus',
+    declaration: 'export type AuthorizationStatus = \'authorized\' | \'cancelled\';',
+  },
   {
   {
     name: 'BackendRegistry',
     name: 'BackendRegistry',
     declaration: 'export class BackendRegistry {\n    register(name: string, backend: StorageBackend): () => void;\n    get(name: string): StorageBackend;\n    names(): string[];\n}',
     declaration: 'export class BackendRegistry {\n    register(name: string, backend: StorageBackend): () => void;\n    get(name: string): StorageBackend;\n    names(): string[];\n}',

+ 15 - 0
pnpm-lock.yaml

@@ -3802,6 +3802,21 @@ importers:
         specifier: workspace:^
         specifier: workspace:^
         version: link:../../interaction/user-approval
         version: link:../../interaction/user-approval
 
 
+  packages/credentials/authorization:
+    devDependencies:
+      '@deepseek-ai/cordis':
+        specifier: workspace:^
+        version: link:../../../vendor/cordis
+      '@deepseek-ai/dsh-credentials':
+        specifier: workspace:^
+        version: link:../credentials
+      '@deepseek-ai/dsh-invariants':
+        specifier: workspace:^
+        version: link:../../runtime-diagnostics/invariants
+      '@deepseek-ai/dsh-llm':
+        specifier: workspace:^
+        version: link:../../llm/llm
+
   packages/credentials/credentials:
   packages/credentials/credentials:
     devDependencies:
     devDependencies:
       '@deepseek-ai/cordis':
       '@deepseek-ai/cordis':

+ 13 - 0
scripts/gen-cordis-catalog.ts

@@ -64,6 +64,7 @@ export const SERVICE_PAGE: Record<string, string> = {
   commands: 'commands.md',
   commands: 'commands.md',
   compaction: 'compaction.md',
   compaction: 'compaction.md',
   cordisInspect: 'extensions.md',
   cordisInspect: 'extensions.md',
+  authorization: 'credentials.md',
   credentials: 'credentials.md',
   credentials: 'credentials.md',
   directoryPicker: 'workspace.md',
   directoryPicker: 'workspace.md',
   dynamicCordisRunner: 'extensions.md',
   dynamicCordisRunner: 'extensions.md',
@@ -172,6 +173,7 @@ export const EVENT_SCOPE_PAGE: Record<string, string> = {
   'approval': 'approval.md',
   'approval': 'approval.md',
   'commands': 'commands.md',
   'commands': 'commands.md',
   'cordis': 'extensions.md',
   'cordis': 'extensions.md',
+  'authorization': 'credentials.md',
   'credentials': 'credentials.md',
   'credentials': 'credentials.md',
   'domain': 'storage.md',
   'domain': 'storage.md',
   'fs': 'filesystem.md',
   'fs': 'filesystem.md',
@@ -464,6 +466,17 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
   SettingsPathOp: 'settings.md',
   SettingsPathOp: 'settings.md',
   SettingsDescribeOptions: 'settings.md',
   SettingsDescribeOptions: 'settings.md',
   SettingsUpdateSource: 'settings.md',
   SettingsUpdateSource: 'settings.md',
+  AuthorizationEntry: 'credentials.md',
+  AuthorizationFlow: 'credentials.md',
+  AuthorizationInteraction: 'credentials.md',
+  AuthorizationMethod: 'credentials.md',
+  AuthorizationNotice: 'credentials.md',
+  AuthorizationOutcome: 'credentials.md',
+  AuthorizationPrompt: 'credentials.md',
+  AuthorizationRequest: 'credentials.md',
+  AuthorizationSession: 'credentials.md',
+  AuthorizationSettlement: 'credentials.md',
+  AuthorizationStatus: 'credentials.md',
   CredentialRef: 'credentials.md',
   CredentialRef: 'credentials.md',
   CredentialKey: 'credentials.md',
   CredentialKey: 'credentials.md',
   CredentialInfo: 'credentials.md',
   CredentialInfo: 'credentials.md',

+ 9 - 0
scripts/gen-doc-graphs.ts

@@ -189,6 +189,15 @@ const SERVICE_ROLES: ServiceRole[] = [
     consumers: ['llm-deepseek', 'llm-pi-ai', 'apiproxy'],
     consumers: ['llm-deepseek', 'llm-pi-ai', 'apiproxy'],
     note: 'Configuration carries references to secrets; providers own the values. Consumers resolve per operation, so a rotated credential reaches the very next request; the web gateway exposes value-free views and write-only storage.',
     note: 'Configuration carries references to secrets; providers own the values. Consumers resolve per operation, so a rotated credential reaches the very next request; the web gateway exposes value-free views and write-only storage.',
   },
   },
+  {
+    key: 'authorization',
+    pkg: 'authorization',
+    title: 'Authorization flow registry',
+    mode: 'seam',
+    implementations: [],
+    consumers: ['llm-pi-ai'],
+    note: 'Flows are registered by the plugin that knows how to obtain one credential and keyed by the record they write; the seam owns the conversation and the one-attempt-per-key lifecycle, never the protocol.',
+  },
   {
   {
     key: 'sessionTelemetry',
     key: 'sessionTelemetry',
     pkg: 'session-telemetry',
     pkg: 'session-telemetry',

+ 1 - 0
scripts/verify-package-readme-model-experience.ts

@@ -133,6 +133,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
   'packages/settings/settings-file': { kind: 'indirect', reason: 'The file provider stores and publishes namespace sections; consumers of ctx.settings own any model-facing behavior.' },
   'packages/settings/settings-file': { kind: 'indirect', reason: 'The file provider stores and publishes namespace sections; consumers of ctx.settings own any model-facing behavior.' },
   'packages/credentials/credentials': { kind: 'indirect', reason: 'The seam resolves credential references; the consuming adapter owns every model-facing use a value authorizes.' },
   'packages/credentials/credentials': { kind: 'indirect', reason: 'The seam resolves credential references; the consuming adapter owns every model-facing use a value authorizes.' },
   'packages/credentials/credentials-local': { kind: 'indirect', reason: 'The file/environment provider stores credential values; consumers of ctx.credentials own any model-facing behavior.' },
   'packages/credentials/credentials-local': { kind: 'indirect', reason: 'The file/environment provider stores credential values; consumers of ctx.credentials own any model-facing behavior.' },
+  'packages/credentials/authorization': { kind: 'none', reason: 'A configuration-time conversation with a human; no flow, notice, or prompt reaches a model request.' },
   'packages/util/atomic-write': { kind: 'none', reason: 'Pure filesystem write primitive; registers nothing model-facing.' },
   'packages/util/atomic-write': { kind: 'none', reason: 'Pure filesystem write primitive; registers nothing model-facing.' },
   'packages/session/session-telemetry': { kind: 'none', reason: 'The seam observes the session stream and hands redacted copies outward; it registers nothing model-facing.' },
   'packages/session/session-telemetry': { kind: 'none', reason: 'The seam observes the session stream and hands redacted copies outward; it registers nothing model-facing.' },
   'packages/session/session-telemetry-otel': { kind: 'none', reason: 'The backend forwards seam records into the OTel SDK pipeline and registers nothing model-facing.' },
   'packages/session/session-telemetry-otel': { kind: 'none', reason: 'The backend forwards seam records into the OTel SDK pipeline and registers nothing model-facing.' },

+ 1 - 0
tsconfig.base.json

@@ -83,6 +83,7 @@
       "@deepseek-ai/dsh-commands/types": ["./packages/interaction/commands/src/types.ts"],
       "@deepseek-ai/dsh-commands/types": ["./packages/interaction/commands/src/types.ts"],
       "@deepseek-ai/dsh-jobs/brand": ["./packages/jobs/jobs/src/brand.ts"],
       "@deepseek-ai/dsh-jobs/brand": ["./packages/jobs/jobs/src/brand.ts"],
       "@deepseek-ai/dsh-credentials/types": ["./packages/credentials/credentials/src/types.ts"],
       "@deepseek-ai/dsh-credentials/types": ["./packages/credentials/credentials/src/types.ts"],
+      "@deepseek-ai/dsh-authorization/types": ["./packages/credentials/authorization/src/types.ts"],
       "@deepseek-ai/dsh-settings/types": ["./packages/settings/settings/src/types.ts"],
       "@deepseek-ai/dsh-settings/types": ["./packages/settings/settings/src/types.ts"],
       "@deepseek-ai/dsh-api-remotes/types": ["./packages/api/remotes/src/types.ts"],
       "@deepseek-ai/dsh-api-remotes/types": ["./packages/api/remotes/src/types.ts"],
       "@deepseek-ai/dsh-api-remotes/invariant": ["./packages/api/remotes/src/invariant.ts"],
       "@deepseek-ai/dsh-api-remotes/invariant": ["./packages/api/remotes/src/invariant.ts"],

+ 1 - 0
tsconfig.host.json

@@ -154,6 +154,7 @@
     { "path": "./packages/settings/settings-file" },
     { "path": "./packages/settings/settings-file" },
     { "path": "./packages/credentials/credentials" },
     { "path": "./packages/credentials/credentials" },
     { "path": "./packages/credentials/credentials-local" },
     { "path": "./packages/credentials/credentials-local" },
+    { "path": "./packages/credentials/authorization" },
     { "path": "./packages/session-query/tool-session-query" },
     { "path": "./packages/session-query/tool-session-query" },
     { "path": "./packages/storage/storage" },
     { "path": "./packages/storage/storage" },
     { "path": "./packages/storage/storage-json" },
     { "path": "./packages/storage/storage-json" },