diff --git a/AGENTS.md b/AGENTS.md index 99b31077db..28208b2c87 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,7 +37,7 @@ packages/ @deepseek-ai/dsh- workspaces at packages/// session/ durable session data: persistence, projection, titles, telemetry identity/ anonymous identity 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 interaction/ approval/interaction capabilities, permission, commands, ask-user boot/ shared app-bin glue diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index e696bc3423..dbafe8c766 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -2,5 +2,5 @@ # 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 docs/capability-seams.md -capability-seams.md: a84a6d6e0836524e1f39f2fd067ae1f6570741a5 -capability-seams.zh.md: b6b0aba72729f9b9b752347ab00cd76a45c248d9 +capability-seams.md: 9e99ebbdc0e3af22f9939c690ead479d4d20b00c +capability-seams.zh.md: 611902310e3472e05eaa92985011eb852bbeee6d diff --git a/docs/capability-seams.md b/docs/capability-seams.md index a84a6d6e08..9e99ebbdc0 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -51,6 +51,8 @@ flowchart LR pkg_credentials["credentials"] svc_credentials["ctx.credentials
Credential seam"] pkg_credentials_local["credentials-local"] + pkg_authorization["authorization"] + svc_authorization["ctx.authorization
Authorization flow registry"] pkg_session_telemetry["session-telemetry"] svc_sessionTelemetry["ctx.sessionTelemetry
Session telemetry seam"] pkg_session_telemetry_otel["session-telemetry-otel"] @@ -210,6 +212,7 @@ flowchart LR pkg_approval --> svc_approval pkg_attachment --> svc_attachments pkg_attachment_local --> svc_attachments + pkg_authorization --> svc_authorization pkg_bash_local --> svc_shell pkg_bash_sandbox --> svc_shell pkg_code_runtime --> svc_codeRuntime @@ -315,6 +318,7 @@ flowchart LR svc_approval --> pkg_tools svc_attachments --> pkg_host_runtime svc_attachments --> pkg_llm_pi_ai + svc_authorization --> pkg_llm_pi_ai svc_clientModules --> pkg_hmr svc_codeRuntime --> pkg_tools 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.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.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.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. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index b6b0aba727..611902310e 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -53,6 +53,8 @@ flowchart LR pkg_credentials["credentials"] svc_credentials["ctx.credentials
Credential seam"] pkg_credentials_local["credentials-local"] + pkg_authorization["authorization"] + svc_authorization["ctx.authorization
Authorization flow registry"] pkg_session_telemetry["session-telemetry"] svc_sessionTelemetry["ctx.sessionTelemetry
Session telemetry seam"] pkg_session_telemetry_otel["session-telemetry-otel"] @@ -212,6 +214,7 @@ flowchart LR pkg_approval --> svc_approval pkg_attachment --> svc_attachments pkg_attachment_local --> svc_attachments + pkg_authorization --> svc_authorization pkg_bash_local --> svc_shell pkg_bash_sandbox --> svc_shell pkg_code_runtime --> svc_codeRuntime @@ -317,6 +320,7 @@ flowchart LR svc_approval --> pkg_tools svc_attachments --> pkg_host_runtime svc_attachments --> pkg_llm_pi_ai + svc_authorization --> pkg_llm_pi_ai svc_clientModules --> pkg_hmr svc_codeRuntime --> pkg_tools 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.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.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.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) | - | 等待所有已配置后端就绪,然后将领域形态发布为一个受生命周期约束的服务,用于类型化持久状态。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index cca52f4aee..6ddf964471 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # 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 docs/config-catalog.md -config-catalog.md: f4e813238b89ef32c097f9ee274347e37ba11efc -config-catalog.zh.md: 4eec1d6d172365d9a16f420a639d09a3bbae1147 +config-catalog.md: b93b91f12703ea52225e1202d6cdc376a0b96331 +config-catalog.zh.md: 2562cae37ad4ebce7646b80d7c54cc59bcfd0abf diff --git a/docs/config-catalog.md b/docs/config-catalog.md index f4e813238b..b93b91f127 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -3192,6 +3192,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-agent` ([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts)) - `@deepseek-ai/dsh-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-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-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)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 4eec1d6d17..2562cae37a 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -3195,6 +3195,7 @@ export interface Config { - `@deepseek-ai/dsh-agent`([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts)) - `@deepseek-ai/dsh-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-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-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)) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index f34d32a007..b7339dbd00 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # 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 docs/event-producer-consumer.md -event-producer-consumer.md: 11dbce044cdbcae1f7ee49a6b5b4d0d369f80bf8 -event-producer-consumer.zh.md: ef9b7ead7e9951b68cbf84f6031d60411e46480f +event-producer-consumer.md: f5ead70b691b43bf9de4507d6753e7066c2d2013 +event-producer-consumer.zh.md: 7aa0f03492561e77c94ab36b5da1ee4b4346d861 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 11dbce044c..f5ead70b69 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -22,7 +22,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:178`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, `apiproxy`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server` | | `agent/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` | -| `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-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` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index ef9b7ead7e..7aa0f03492 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -24,7 +24,8 @@ | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:178`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, `apiproxy`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server` | | `agent/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` | -| `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-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` | diff --git a/docs/subsystems/credentials.i18n.yaml b/docs/subsystems/credentials.i18n.yaml index 68696cb3d5..6c56090ca1 100644 --- a/docs/subsystems/credentials.i18n.yaml +++ b/docs/subsystems/credentials.i18n.yaml @@ -2,5 +2,5 @@ # 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 docs/subsystems/credentials.md -credentials.md: 59ab18d3f9fb9db7668f766484fc6a7ac7d3c950 -credentials.zh.md: 5b941c7f360bb3904f91a16bdfd862f5d13d6b39 +credentials.md: aa3230379205eabec0b9e51596bbbbfb7ef491b9 +credentials.zh.md: ce9db1709555950d4f4e30798c560922a54d8674 diff --git a/docs/subsystems/credentials.md b/docs/subsystems/credentials.md index 59ab18d3f9..aa32303792 100644 --- a/docs/subsystems/credentials.md +++ b/docs/subsystems/credentials.md @@ -57,6 +57,65 @@ interface CredentialInfo { Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — this section is byte-identical in both language sides of the page. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + +### `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 +``` + +Source: [`packages/credentials/authorization/src/index.ts:163`](../../packages/credentials/authorization/src/index.ts) + ### `ctx.credentials` — `CredentialProvider` (abstract seam) @@ -151,6 +210,30 @@ abstract deleteRecord(key: CredentialKey): Promise Source: [`packages/credentials/credentials/src/index.ts:141`](../../packages/credentials/credentials/src/index.ts) + + +### `authorization/*` events + + + +#### `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) + ### `credentials/*` events diff --git a/docs/subsystems/credentials.zh.md b/docs/subsystems/credentials.zh.md index 5b941c7f36..ce9db17095 100644 --- a/docs/subsystems/credentials.zh.md +++ b/docs/subsystems/credentials.zh.md @@ -57,6 +57,65 @@ interface CredentialInfo { Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — this section is byte-identical in both language sides of the page. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + +### `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 +``` + +Source: [`packages/credentials/authorization/src/index.ts:163`](../../packages/credentials/authorization/src/index.ts) + ### `ctx.credentials` — `CredentialProvider` (abstract seam) @@ -151,6 +210,30 @@ abstract deleteRecord(key: CredentialKey): Promise Source: [`packages/credentials/credentials/src/index.ts:141`](../../packages/credentials/credentials/src/index.ts) + + +### `authorization/*` events + + + +#### `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) + ### `credentials/*` events diff --git a/packages/README.md b/packages/README.md index a410d7148d..c455feb384 100644 --- a/packages/README.md +++ b/packages/README.md @@ -46,7 +46,7 @@ Groups hold `packages///`; names stay `@deepseek-ai/dsh-`. **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-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 | -| [`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 | | [`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 | diff --git a/packages/README.zh.md b/packages/README.zh.md index 780d1356f2..14ab37d08e 100644 --- a/packages/README.zh.md +++ b/packages/README.zh.md @@ -46,7 +46,7 @@ npm scope 为 `@deepseek-ai/dsh-*`;Cordis `Service` 子类和函数插件通 | [`session/`](session/README.md) | 持久会话数据平面:持久化 seam + JSONL/SQLite 后端、投影 seam、基于日志的标题、会话上报 | 产品:稳定 API | | [`session-query/`](session-query/README.md) | 会话检索系列:逻辑语料库、有界读取、血缘、事件关系、语义过滤和 SQLite 全文搜索 | 产品:稳定 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 | | [`workspace/`](workspace/README.md) | Workspace 实体 | 产品:稳定 API | | [`sdk/`](sdk/README.md) | 进程外运行时 SDK:JSON-RPC 协议、TypeScript 客户端和服务器插件 | 产品:稳定 API | diff --git a/packages/credentials/README.i18n.yaml b/packages/credentials/README.i18n.yaml index 38cbbb2968..2b34534bfa 100644 --- a/packages/credentials/README.i18n.yaml +++ b/packages/credentials/README.i18n.yaml @@ -2,5 +2,5 @@ # 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/README.md -README.md: 75e3941ff6421ca7e41a71ab2bfe8b30209ce598 -README.zh.md: d7c958689fdffc82830a82b04e2a06802bc81634 +README.md: 032f0f38319e276b613241ba89a956a341eaa09c +README.zh.md: 21e66ef6c8375ef1e4834b05f30901b0e38aebad diff --git a/packages/credentials/README.md b/packages/credentials/README.md index 75e3941ff6..032f0f3831 100644 --- a/packages/credentials/README.md +++ b/packages/credentials/README.md @@ -1,14 +1,15 @@ -# credentials/ — credential references +# credentials/ — credentials and authorization 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 | |---|---|---| -| [`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` | +| [`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). diff --git a/packages/credentials/README.zh.md b/packages/credentials/README.zh.md index d7c958689f..21e66ef6c8 100644 --- a/packages/credentials/README.zh.md +++ b/packages/credentials/README.zh.md @@ -1,14 +1,15 @@ -# credentials/:凭据引用 +# credentials/:凭据与授权 [English](README.md) | 中文 -凭据能力家族将引用解析与提供方分离: +凭据能力家族将引用解析与提供方分离,并把二者与"必须开口去要才能拿到的凭据"再分开: | 包 | 角色 | ctx 键 | |---|---|---| -| [`credentials/`](credentials/README.md) | 凭据引用 seam | `ctx.credentials` | +| [`credentials/`](credentials/README.md) | 凭据引用与凭据记录 seam | `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)。 diff --git a/packages/credentials/authorization/README.i18n.yaml b/packages/credentials/authorization/README.i18n.yaml new file mode 100644 index 0000000000..cdec37bfab --- /dev/null +++ b/packages/credentials/authorization/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/credentials/authorization/README.md +README.md: f73d2231456d0873cef7ea047fe537629a7552e0 +README.zh.md: ecceaa4f90a971deebe89e8dd6e51157aa0072a0 diff --git a/packages/credentials/authorization/README.md b/packages/credentials/authorization/README.md new file mode 100644 index 0000000000..f73d223145 --- /dev/null +++ b/packages/credentials/authorization/README.md @@ -0,0 +1,74 @@ +# dsh-authorization + +English | [中文](README.zh.md) + +Authorization Service Definition (`ctx.authorization`). Some credentials cannot be configured, only obtained: getting one means a conversation with a human — open this page, paste that code, pick an account. This seam owns that conversation and the lifecycle around it, and never the protocol. + +**A flow is a plugin's knowledge of how to get its own credential.** It is registered under the [`CredentialKey`](../credentials/README.md#two-key-spaces-two-questions) it writes, so a flow says which record it produces and, through that key's scope, which plugin answers for the format inside it. A second authorization protocol arrives as another flow, not as another seam. + +**The flow owns the write.** `run()` resolving means the record is already committed through `ctx.credentials`; the seam confirms it and refuses a flow that resolved without one. Committing inside the flow is what lets a library that persists through its own store adapter stay the single writer instead of being copied back out and written twice. + +**The interaction travels with the request, not a registry.** Whoever starts an authorization is the one who can talk to the human about it, so prompts reach exactly the surface that asked and a headless caller supplies an interaction that declines. There is no ambient provider to be absent, and no question about which of two open pages a prompt belongs to. + +## Surface + +```ts +import type { Context } from '@deepseek-ai/cordis' +import type { AuthorizationSession } from '@deepseek-ai/dsh-authorization' +import { credentialKey } from '@deepseek-ai/dsh-credentials' + +declare const ctx: Context +declare const exchange: (signal: AbortSignal) => Promise + +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). diff --git a/packages/credentials/authorization/README.zh.md b/packages/credentials/authorization/README.zh.md new file mode 100644 index 0000000000..ecceaa4f90 --- /dev/null +++ b/packages/credentials/authorization/README.zh.md @@ -0,0 +1,74 @@ +# dsh-authorization + +[English](README.md) | 中文 + +授权 Service Definition(`ctx.authorization`)。有些凭据无法配置,只能获取:拿到它意味着与人对话——打开这个页面、粘贴那个码、选一个账号。本 seam 拥有这段对话及其生命周期,但从不拥有协议本身。 + +**flow 是某个插件"如何取得自己那份凭据"的知识。** 它以自己写入的 [`CredentialKey`](../credentials/README.md#two-key-spaces-two-questions) 注册,因此 flow 声明了自己产出哪条记录,并通过该键的 scope 声明由哪个插件为记录内部的格式负责。第二种授权协议以另一个 flow 的形式到来,而不是另一个 seam。 + +**写入由 flow 拥有。** `run()` 返回即表示记录已经通过 `ctx.credentials` 提交;seam 随后核实,并拒绝那些返回时没留下记录的 flow。让提交发生在 flow 内部,才能使一个通过自有 store 适配器持久化的库保持为唯一写入方,而不是把凭据复制出来再写第二遍。 + +**交互随请求传入,而非注册表。** 发起授权的一方才是能与人对话的一方,因此提示恰好抵达发问的那个界面,无头调用方则传入一个直接拒绝的交互实现。这样既不存在"环境提供方缺席"的问题,也不会出现某个提示该归两个已打开页面中哪一个的疑问。 + +## 接口 + +```ts +import type { Context } from '@deepseek-ai/cordis' +import type { AuthorizationSession } from '@deepseek-ai/dsh-authorization' +import { credentialKey } from '@deepseek-ai/dsh-credentials' + +declare const ctx: Context +declare const exchange: (signal: AbortSignal) => Promise + +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) 的情况相同。 diff --git a/packages/credentials/authorization/package.json b/packages/credentials/authorization/package.json new file mode 100644 index 0000000000..05f9832e0e --- /dev/null +++ b/packages/credentials/authorization/package.json @@ -0,0 +1,51 @@ +{ + "name": "@deepseek-ai/dsh-authorization", + "description": "Authorization seam (ctx.authorization): plugin-owned flows that obtain a credential through a conversation with the human", + "version": "0.0.1-rc.5", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/credentials/authorization" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.js", + "lib/types/**/*.d.ts" + ], + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/dsh-credentials": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/dsh-credentials": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + } +} diff --git a/packages/credentials/authorization/src/index.ts b/packages/credentials/authorization/src/index.ts new file mode 100644 index 0000000000..c88129b7cf --- /dev/null +++ b/packages/credentials/authorization/src/index.ts @@ -0,0 +1,324 @@ +/** + * Service Definition for the authorization capability seam (`ctx.authorization`): + * obtaining a credential nobody can supply from configuration alone, because + * getting it requires a conversation with the human — open this page, paste + * that code, pick an account. + * + * The seam owns the conversation and the lifecycle; it never owns the protocol. + * A plugin that knows how to obtain its own credential registers a flow keyed + * by the `CredentialKey` that flow writes, and the flow talks to whatever + * surface started it through one neutral vocabulary of notices and prompts. So + * a second authorization protocol arrives as another flow rather than as + * another seam, and a surface that renders one flow renders all of them. + * + * ```ts + * const dispose = ctx.authorization.registerFlow({ + * key: credentialKey('llm-pi-ai', 'openai-codex'), + * label: 'ChatGPT (Codex)', + * methods: [{ id: 'oauth', label: 'Sign in with ChatGPT' }], + * async run(session) { + * session.notify({ message: 'Continue in your browser', url }) + * await commitThroughCredentials(await exchange(session.signal)) + * }, + * }) + * ``` + * + * @module @deepseek-ai/dsh-authorization + */ + +import { Context, Service } from '@deepseek-ai/cordis' +import type { CredentialKey } from '@deepseek-ai/dsh-credentials' +import { HarnessError } from '@deepseek-ai/dsh-llm' + +import type { + AuthorizationEntry, AuthorizationMethod, AuthorizationNotice, AuthorizationOutcome, AuthorizationPrompt, + AuthorizationSettlement, +} from './types.ts' + +export type { + AuthorizationEntry, AuthorizationMethod, AuthorizationNotice, AuthorizationOutcome, AuthorizationPrompt, + AuthorizationPromptOption, AuthorizationSettlement, AuthorizationStatus, +} from './types.ts' + +declare module '@deepseek-ai/cordis' { + interface Context { + authorization: AuthorizationService + } + + interface Events { + /** + * One authorization attempt has finished and released its key. Fires for + * every terminal outcome, failures included, so a surface watching a key it + * did not start (a second browser tab) learns the attempt is over. + * @mode emit + * @param key - the credential record the finished attempt was authorizing. + * @param settlement - how it ended, including the `failed` case its caller sees as a thrown error. + */ + 'authorization/settled'(key: CredentialKey, settlement: AuthorizationSettlement): void + } +} + +/** Stable error taxonomy for authorization failures. */ +export class AuthorizationError extends HarnessError { + constructor(message: string, code: string, options?: ErrorOptions) { + super(message, code, options) + this.name = 'AuthorizationError' + } +} + +/** + * What a running flow is given to talk to the human. Every member is scoped to + * one attempt: the flow neither knows nor chooses which surface is listening. + */ +export interface AuthorizationSession { + /** The method id the caller picked, always one this flow declared. */ + readonly method: string + /** Aborted when the caller withdraws or `cancel()` is called for this key. */ + readonly signal: AbortSignal + /** + * Report progress, or tell the human what to do next. Fire-and-forget: a + * surface that cannot render a notice must not stall the flow. + * @param notice - the message, and any page or code it refers to. + */ + notify(notice: AuthorizationNotice): void + /** + * Ask the human a question the flow cannot answer for itself. + * @param prompt - what to ask, and how it should be presented. + * @returns what the human typed, or the chosen option's id. + * @throws when the human declines, or the prompt's own signal withdraws it. + */ + prompt(prompt: AuthorizationPrompt): Promise +} + +/** + * 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 +} + +/** + * 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 +} + +/** 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() + private readonly running = new Map() + + 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 { + 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 { + 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 diff --git a/packages/credentials/authorization/src/invariant.ts b/packages/credentials/authorization/src/invariant.ts new file mode 100644 index 0000000000..ec89229566 --- /dev/null +++ b/packages/credentials/authorization/src/invariant.ts @@ -0,0 +1,45 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-authorization`. + * @module @deepseek-ai/dsh-authorization/invariant + */ + +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-authorization' + +/** Cordis companion plugin name. */ +export const name = 'authorization-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * Install the single-flight release contract: `authorization/settled` names a + * finished attempt, and the seam admits one attempt per key, so the key must + * already be free when the event fires. A slot still held at settlement is + * unrecoverable — every later `begin()` for that key is refused as + * `ALREADY_IN_FLIGHT` until the process restarts — and it is invisible from the + * outside, because a wedged key looks exactly like a busy one. + */ +const install: InvariantInstaller = (ctx: Context, fail: InvariantFailure) => { + ctx.on('authorization/settled', (key) => { + const authorization = ctx.get('authorization') + if (authorization === undefined) { + fail(`authorization/settled for "${key}" emitted without a live authorization service`) + return + } + // A flow withdrawn during its own attempt settles with nothing left to + // describe, which is the disposer's documented behavior rather than a leak. + if (authorization.describe(key)?.inFlight === true) { + fail(`authorization/settled for "${key}" left the key in flight, wedging every later attempt`) + } + }) +} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/credentials/authorization/src/types.ts b/packages/credentials/authorization/src/types.ts new file mode 100644 index 0000000000..ea75db8d7c --- /dev/null +++ b/packages/credentials/authorization/src/types.ts @@ -0,0 +1,91 @@ +/** + * Wire-safe authorization types, free of cordis/service imports so browser type + * chains (apiproxy api → client) can consume them without loading this + * package's Context augmentation. + * @module @deepseek-ai/dsh-authorization/types + */ + +import type { CredentialKey } from '@deepseek-ai/dsh-credentials/types' + +/** One way a flow can obtain its credential, named by the flow that offers it. */ +export interface AuthorizationMethod { + /** Flow-owned identifier, echoed back when a caller picks this method. */ + id: string + /** User-facing label for a picker. */ + label: string +} + +/** A running flow's report to whoever is watching it. Never carries a secret. */ +export interface AuthorizationNotice { + /** What is happening, or what the human must do next. */ + message: string + /** A page the human must open to continue. */ + url?: string + /** A short code the human must enter on that page. */ + code?: string +} + +/** One choice offered by a `select` prompt. */ +export interface AuthorizationPromptOption { + /** Value returned when this option is chosen. */ + id: string + /** User-facing label. */ + label: string + /** Optional extra context rendered by capable surfaces. */ + description?: string +} + +/** + * A question a flow must have answered before it can continue. `secret` differs + * from `text` only in presentation — a surface masks it and keeps it out of + * logs — and `select` answers with the chosen option's `id`. + */ +export type AuthorizationPrompt = { + /** + * Withdraws this prompt alone, leaving the flow running. A flow that races a + * typed code against a browser callback aborts the losing prompt here; the + * whole authorization is cancelled through the request's signal instead. + */ + signal?: AbortSignal +} & ({ + kind: 'text' + message: string + placeholder?: string +} | { + kind: 'secret' + message: string + placeholder?: string +} | { + kind: 'select' + message: string + options: readonly AuthorizationPromptOption[] +}) + +/** How one authorization attempt ended, as its own caller sees it. */ +export type AuthorizationStatus = 'authorized' | 'cancelled' + +/** + * How one attempt ended, as an onlooker sees it. A failure reaches its caller + * as a thrown error rather than an outcome, so `failed` exists only here — on + * the event stream, where a watcher that did not start the attempt has no + * other way to tell a refusal from a breakage. + */ +export type AuthorizationSettlement = AuthorizationStatus | 'failed' + +/** The result of one `begin()` attempt. */ +export interface AuthorizationOutcome { + /** `authorized` once the record is committed and observed; `cancelled` when the human or caller withdrew. */ + status: AuthorizationStatus +} + +/** A registered flow as a surface sees it: what it authorizes and whether it is busy. */ +export interface AuthorizationEntry { + /** The credential record this flow writes. */ + key: CredentialKey + /** User-facing name of what is being authorized. */ + label: string + /** The methods this flow offers, most preferred first. */ + methods: readonly AuthorizationMethod[] + /** Whether an attempt for this key is running right now. */ + inFlight: boolean +} diff --git a/packages/credentials/authorization/tests/authorization.spec.ts b/packages/credentials/authorization/tests/authorization.spec.ts new file mode 100644 index 0000000000..f5428f3b75 --- /dev/null +++ b/packages/credentials/authorization/tests/authorization.spec.ts @@ -0,0 +1,283 @@ +import { describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { credentialKey } from '@deepseek-ai/dsh-credentials' +import AuthorizationService, { + type AuthorizationFlow, + type AuthorizationInteraction, + type AuthorizationSession, +} from '@deepseek-ai/dsh-authorization' +import { MemoryCredentials } from './memory.ts' + +const KEY = credentialKey('llm-pi-ai', 'openai-codex') +const OTHER = credentialKey('llm-pi-ai', 'anthropic') + +/** A context with the record store the seam confirms commits against. */ +async function harness(): Promise { + 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, +): 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((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() + const started = Promise.withResolvers() + 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() + 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/) + }) +}) diff --git a/packages/credentials/authorization/tests/invariant.spec.ts b/packages/credentials/authorization/tests/invariant.spec.ts new file mode 100644 index 0000000000..9f49bdf58a --- /dev/null +++ b/packages/credentials/authorization/tests/invariant.spec.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { credentialKey } from '@deepseek-ai/dsh-credentials' +import InvariantRegistry from '@deepseek-ai/dsh-invariants' +import AuthorizationService from '@deepseek-ai/dsh-authorization' +import * as AuthorizationInvariant from '../src/invariant.ts' +import { MemoryCredentials } from './memory.ts' + +const KEY = credentialKey('llm-pi-ai', 'openai-codex') + +describe('authorization invariant companion', () => { + it('accepts an attempt that released its key before settling', async () => { + const ctx = new Context() + await ctx.plugin(InvariantRegistry) + await ctx.plugin(AuthorizationInvariant) + await ctx.plugin(MemoryCredentials) + await ctx.plugin(AuthorizationService) + ctx.authorization.registerFlow({ + key: KEY, + label: 'ChatGPT (Codex)', + methods: [{ id: 'oauth', label: 'Sign in' }], + run: () => ctx.credentials + .modifyRecord(KEY, () => Promise.resolve({ kind: 'grant', payload: {} })) + .then(() => undefined), + }) + + await expect(ctx.authorization.begin({ + key: KEY, + interaction: { notify: () => {}, prompt: () => Promise.reject(new Error('unused')) }, + })).resolves.toEqual({ status: 'authorized' }) + }) + + it('fails a settlement that left its key in flight', async () => { + const ctx = new Context() + await ctx.plugin(InvariantRegistry) + await ctx.plugin(AuthorizationInvariant) + await ctx.plugin(MemoryCredentials) + await ctx.plugin(AuthorizationService) + const started = Promise.withResolvers() + 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/) + }) +}) diff --git a/packages/credentials/authorization/tests/memory.ts b/packages/credentials/authorization/tests/memory.ts new file mode 100644 index 0000000000..94ebe091d2 --- /dev/null +++ b/packages/credentials/authorization/tests/memory.ts @@ -0,0 +1,67 @@ +import { CredentialProvider } from '@deepseek-ai/dsh-credentials' +import type { + CredentialInfo, + CredentialKey, + CredentialRecord, + CredentialRecordEntry, + CredentialRecordInfo, + CredentialRef, + ResolvedCredential, +} from '@deepseek-ai/dsh-credentials' + +/** + * In-memory credentials provider for the authorization suite. Only the record + * half is exercised — the seam's whole interest in this service is whether a + * flow left a record behind — so the reference half answers "nothing stored". + */ +export class MemoryCredentials extends CredentialProvider { + private readonly records = new Map() + + override resolve(_ref: CredentialRef): Promise { + return Promise.resolve(undefined) + } + + override describe(_ref: CredentialRef): Promise { + return Promise.resolve({ configured: false, writable: true }) + } + + override set(_ref: CredentialRef, _value: string): Promise { + return Promise.resolve() + } + + override unset(_ref: CredentialRef): Promise { + return Promise.resolve() + } + + override readRecord(key: CredentialKey): Promise { + return Promise.resolve(this.records.get(key)) + } + + override describeRecord(key: CredentialKey): Promise { + 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 { + return Promise.resolve([...this.records].map(([key, record]) => ({ key, kind: record.kind }))) + } + + override async modifyRecord( + key: CredentialKey, + mutate: (current: CredentialRecord | undefined) => Promise, + ): Promise { + 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 { + if (this.records.delete(key)) this.ctx.emit('credentials/record-updated', key) + return Promise.resolve() + } +} diff --git a/packages/credentials/authorization/tsconfig.json b/packages/credentials/authorization/tsconfig.json new file mode 100644 index 0000000000..cdf84f6f5d --- /dev/null +++ b/packages/credentials/authorization/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../credentials" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../runtime-diagnostics/invariants" + } + ] +} diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 398121fa3d..78b9368986 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -457,6 +457,44 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'authorization', + summary: '`ctx.authorization`: a registry of credential-obtaining flows, one attempt at a time per key.', + description: '`ctx.authorization`: a registry of credential-obtaining flows, one attempt at a time per key.', + methods: [ + { + signature: 'registerFlow(flow: AuthorizationFlow): () => void', + description: 'Offer a way to obtain one credential. One flow per key: two plugins claiming the same key would each write a record in their own format, and whichever ran last would leave the other reading a payload it cannot parse.', + parameters: [{ name: 'flow', description: 'the key it writes, its label, its methods, and its runner.' }], + returns: 'Disposer that withdraws this flow.', + throws: ['{AuthorizationError} code `DUPLICATE_FLOW` when the key is already claimed.'], + }, + { + signature: 'list(): readonly AuthorizationEntry[]', + description: 'Every registered flow, for a surface listing what can be authorized.', + parameters: [], + returns: 'one entry per flow, in registration order.', + }, + { + signature: 'describe(key: CredentialKey): AuthorizationEntry | undefined', + description: 'One registered flow.', + parameters: [{ name: 'key', description: 'the credential record to ask about.' }], + returns: 'the entry, or undefined when no flow claims that key.', + }, + { + signature: 'cancel(key: CredentialKey): void', + description: 'Withdraw the attempt running for a key, if any. Separate from the request\'s own signal because a request/response transport answers a Cancel button on a second call, with no handle on the first one\'s signal.', + parameters: [{ name: 'key', description: 'the credential record whose attempt should stop.' }], + }, + { + signature: 'async begin(request: AuthorizationRequest): Promise', + 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', 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.', 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', mode: 'emit', @@ -2902,6 +2948,54 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: '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;\n}', + }, + { + name: 'AuthorizationInteraction', + declaration: 'export interface AuthorizationInteraction {\n notify(notice: AuthorizationNotice): void;\n prompt(prompt: AuthorizationPrompt): Promise;\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;\n}', + }, + { + name: 'AuthorizationSettlement', + declaration: 'export type AuthorizationSettlement = AuthorizationStatus | \'failed\';', + }, + { + name: 'AuthorizationStatus', + declaration: 'export type AuthorizationStatus = \'authorized\' | \'cancelled\';', + }, { name: 'BackendRegistry', declaration: 'export class BackendRegistry {\n register(name: string, backend: StorageBackend): () => void;\n get(name: string): StorageBackend;\n names(): string[];\n}', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 74c628dd01..3c5eab9c8f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3802,6 +3802,21 @@ importers: specifier: workspace:^ 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: devDependencies: '@deepseek-ai/cordis': diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 3aa31ba6f0..483518efd1 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -64,6 +64,7 @@ export const SERVICE_PAGE: Record = { commands: 'commands.md', compaction: 'compaction.md', cordisInspect: 'extensions.md', + authorization: 'credentials.md', credentials: 'credentials.md', directoryPicker: 'workspace.md', dynamicCordisRunner: 'extensions.md', @@ -172,6 +173,7 @@ export const EVENT_SCOPE_PAGE: Record = { 'approval': 'approval.md', 'commands': 'commands.md', 'cordis': 'extensions.md', + 'authorization': 'credentials.md', 'credentials': 'credentials.md', 'domain': 'storage.md', 'fs': 'filesystem.md', @@ -464,6 +466,17 @@ export const LINK_MAP: Readonly> = { SettingsPathOp: 'settings.md', SettingsDescribeOptions: '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', CredentialKey: 'credentials.md', CredentialInfo: 'credentials.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 420bad476c..5e19c20113 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -189,6 +189,15 @@ const SERVICE_ROLES: ServiceRole[] = [ 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.', }, + { + 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', pkg: 'session-telemetry', diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 31399faf35..02d0e66c0e 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -133,6 +133,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { '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-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/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.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 91e623e2d1..8d8d5e1433 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -83,6 +83,7 @@ "@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-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-api-remotes/types": ["./packages/api/remotes/src/types.ts"], "@deepseek-ai/dsh-api-remotes/invariant": ["./packages/api/remotes/src/invariant.ts"], diff --git a/tsconfig.host.json b/tsconfig.host.json index 95036f727c..f37b98ea57 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -154,6 +154,7 @@ { "path": "./packages/settings/settings-file" }, { "path": "./packages/credentials/credentials" }, { "path": "./packages/credentials/credentials-local" }, + { "path": "./packages/credentials/authorization" }, { "path": "./packages/session-query/tool-session-query" }, { "path": "./packages/storage/storage" }, { "path": "./packages/storage/storage-json" },