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.
This commit is contained in:
Yichen Jiang
2026-08-20 17:58:38 +08:00
parent 86a9f8c862
commit 732a7361f5
36 changed files with 1468 additions and 23 deletions
+1 -1
View File
@@ -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
View File
@@ -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.md: 9e99ebbdc0e3af22f9939c690ead479d4d20b00c
capability-seams.zh.md: b6b0aba72729f9b9b752347ab00cd76a45c248d9 capability-seams.zh.md: 611902310e3472e05eaa92985011eb852bbeee6d
+5
View File
@@ -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
View File
@@ -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
View File
@@ -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.md: b93b91f12703ea52225e1202d6cdc376a0b96331
config-catalog.zh.md: 4eec1d6d172365d9a16f420a639d09a3bbae1147 config-catalog.zh.md: 2562cae37ad4ebce7646b80d7c54cc59bcfd0abf
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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.md: f5ead70b691b43bf9de4507d6753e7066c2d2013
event-producer-consumer.zh.md: ef9b7ead7e9951b68cbf84f6031d60411e46480f event-producer-consumer.zh.md: 7aa0f03492561e77c94ab36b5da1ee4b4346d861
+2 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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.md: aa3230379205eabec0b9e51596bbbbfb7ef491b9
credentials.zh.md: 5b941c7f360bb3904f91a16bdfd862f5d13d6b39 credentials.zh.md: ce9db1709555950d4f4e30798c560922a54d8674
+83
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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) | 进程外运行时 SDKJSON-RPC 协议、TypeScript 客户端和服务器插件 | 产品:稳定 API | | [`sdk/`](sdk/README.md) | 进程外运行时 SDKJSON-RPC 协议、TypeScript 客户端和服务器插件 | 产品:稳定 API |
+2 -2
View File
@@ -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.md: 032f0f38319e276b613241ba89a956a341eaa09c
README.zh.md: d7c958689fdffc82830a82b04e2a06802bc81634 README.zh.md: 21e66ef6c8375ef1e4834b05f30901b0e38aebad
+5 -4
View File
@@ -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
View File
@@ -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)。
@@ -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
@@ -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).
@@ -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) 的情况相同。
@@ -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:^"
}
}
@@ -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
@@ -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))
@@ -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
}
@@ -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/)
})
})
@@ -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/)
})
})
@@ -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()
}
}
@@ -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"
}
]
}
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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',
@@ -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
View File
@@ -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
View File
@@ -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" },