mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
feat(authorization): obtain a credential by asking the human
Some credentials cannot be configured, only obtained: getting one means a conversation — open this page, paste that code, pick an account. The new seam owns that conversation and the one-attempt-per-key lifecycle, and never the protocol, so a second authorization protocol arrives as another flow rather than as another seam. A flow is registered under the CredentialKey it writes, which is also how the seam knows which plugin answers for the format inside that record. The flow owns the write: run() resolving means the record is already committed through ctx.credentials, and the seam confirms it. That keeps a library persisting through its own store adapter the single writer instead of being copied back out and written twice. The interaction travels with the request rather than a registry, because whoever starts an authorization is the one who can talk to the human about it. A request already withdrawn never claims the key and never starts the flow — relying on each flow to check its signal before the first await would let one that does not hang holding the key.
This commit is contained in:
@@ -37,7 +37,7 @@ packages/ @deepseek-ai/dsh-<pkg> workspaces at packages/<group>/<pkg>/
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -51,6 +51,8 @@ flowchart LR
|
||||
pkg_credentials["credentials"]
|
||||
svc_credentials["ctx.credentials<br/>Credential seam"]
|
||||
pkg_credentials_local["credentials-local"]
|
||||
pkg_authorization["authorization"]
|
||||
svc_authorization["ctx.authorization<br/>Authorization flow registry"]
|
||||
pkg_session_telemetry["session-telemetry"]
|
||||
svc_sessionTelemetry["ctx.sessionTelemetry<br/>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. |
|
||||
|
||||
@@ -53,6 +53,8 @@ flowchart LR
|
||||
pkg_credentials["credentials"]
|
||||
svc_credentials["ctx.credentials<br/>Credential seam"]
|
||||
pkg_credentials_local["credentials-local"]
|
||||
pkg_authorization["authorization"]
|
||||
svc_authorization["ctx.authorization<br/>Authorization flow registry"]
|
||||
pkg_session_telemetry["session-telemetry"]
|
||||
svc_sessionTelemetry["ctx.sessionTelemetry<br/>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) | - | 等待所有已配置后端就绪,然后将领域形态发布为一个受生命周期约束的服务,用于类型化持久状态。 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
<a id="ctxauthorization--authorizationservice"></a>
|
||||
|
||||
### `ctx.authorization` — `AuthorizationService`
|
||||
|
||||
`ctx.authorization`: a registry of credential-obtaining flows, one attempt at a time per key.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* Offer a way to obtain one credential. One flow per key: two plugins
|
||||
* claiming the same key would each write a record in their own format, and
|
||||
* whichever ran last would leave the other reading a payload it cannot parse.
|
||||
*
|
||||
* @param flow - the key it writes, its label, its methods, and its runner.
|
||||
* @returns Disposer that withdraws this flow.
|
||||
* @throws {AuthorizationError} code `DUPLICATE_FLOW` when the key is already claimed.
|
||||
*/
|
||||
registerFlow(flow: AuthorizationFlow): () => void
|
||||
|
||||
/**
|
||||
* Every registered flow, for a surface listing what can be authorized.
|
||||
* @returns one entry per flow, in registration order.
|
||||
*/
|
||||
list(): readonly AuthorizationEntry[]
|
||||
|
||||
/**
|
||||
* One registered flow.
|
||||
* @param key - the credential record to ask about.
|
||||
* @returns the entry, or undefined when no flow claims that key.
|
||||
*/
|
||||
describe(key: CredentialKey): AuthorizationEntry | undefined
|
||||
|
||||
/**
|
||||
* Withdraw the attempt running for a key, if any. Separate from the
|
||||
* request's own signal because a request/response transport answers a Cancel
|
||||
* button on a second call, with no handle on the first one's signal.
|
||||
* @param key - the credential record whose attempt should stop.
|
||||
*/
|
||||
cancel(key: CredentialKey): void
|
||||
|
||||
/**
|
||||
* Run one attempt to authorize a key, and report how it ended.
|
||||
*
|
||||
* One attempt per key at a time. A second caller is refused rather than
|
||||
* joined: the two would be prompting different humans through the same flow,
|
||||
* and the second would answer questions the first was asked.
|
||||
*
|
||||
* @param request - the key, the method, the surface, and the cancel signal.
|
||||
* @returns `authorized` once the flow's record is committed and observed,
|
||||
* or `cancelled` when the human or the caller withdrew.
|
||||
* @throws {AuthorizationError} code `NO_FLOW` when nothing claims the key,
|
||||
* `UNKNOWN_METHOD` when the named method is not one the flow offers,
|
||||
* `ALREADY_IN_FLIGHT` when an attempt is already running for the key, or
|
||||
* `NOT_COMMITTED` when the flow resolved without leaving a record behind.
|
||||
*/
|
||||
async begin(request: AuthorizationRequest): Promise<AuthorizationOutcome>
|
||||
```
|
||||
|
||||
Source: [`packages/credentials/authorization/src/index.ts:163`](../../packages/credentials/authorization/src/index.ts)
|
||||
|
||||
<a id="ctxcredentials--credentialprovider-abstract-seam"></a>
|
||||
|
||||
### `ctx.credentials` — `CredentialProvider` (abstract seam)
|
||||
@@ -151,6 +210,30 @@ abstract deleteRecord(key: CredentialKey): Promise<void>
|
||||
|
||||
Source: [`packages/credentials/credentials/src/index.ts:141`](../../packages/credentials/credentials/src/index.ts)
|
||||
|
||||
<a id="authorization-events"></a>
|
||||
|
||||
### `authorization/*` events
|
||||
|
||||
<a id="authorizationsettled--emit"></a>
|
||||
|
||||
#### `authorization/settled` — emit
|
||||
|
||||
One authorization attempt has finished and released its key. Fires for every terminal outcome, failures included, so a surface watching a key it did not start (a second browser tab) learns the attempt is over.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* One authorization attempt has finished and released its key. Fires for
|
||||
* every terminal outcome, failures included, so a surface watching a key it
|
||||
* did not start (a second browser tab) learns the attempt is over.
|
||||
* @mode emit
|
||||
* @param key - the credential record the finished attempt was authorizing.
|
||||
* @param settlement - how it ended, including the `failed` case its caller sees as a thrown error.
|
||||
*/
|
||||
'authorization/settled'(key: CredentialKey, settlement: AuthorizationSettlement): void
|
||||
```
|
||||
|
||||
Source: [`packages/credentials/authorization/src/index.ts:57`](../../packages/credentials/authorization/src/index.ts)
|
||||
|
||||
<a id="credentials-events"></a>
|
||||
|
||||
### `credentials/*` events
|
||||
|
||||
@@ -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).
|
||||
|
||||
<a id="ctxauthorization--authorizationservice"></a>
|
||||
|
||||
### `ctx.authorization` — `AuthorizationService`
|
||||
|
||||
`ctx.authorization`: a registry of credential-obtaining flows, one attempt at a time per key.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* Offer a way to obtain one credential. One flow per key: two plugins
|
||||
* claiming the same key would each write a record in their own format, and
|
||||
* whichever ran last would leave the other reading a payload it cannot parse.
|
||||
*
|
||||
* @param flow - the key it writes, its label, its methods, and its runner.
|
||||
* @returns Disposer that withdraws this flow.
|
||||
* @throws {AuthorizationError} code `DUPLICATE_FLOW` when the key is already claimed.
|
||||
*/
|
||||
registerFlow(flow: AuthorizationFlow): () => void
|
||||
|
||||
/**
|
||||
* Every registered flow, for a surface listing what can be authorized.
|
||||
* @returns one entry per flow, in registration order.
|
||||
*/
|
||||
list(): readonly AuthorizationEntry[]
|
||||
|
||||
/**
|
||||
* One registered flow.
|
||||
* @param key - the credential record to ask about.
|
||||
* @returns the entry, or undefined when no flow claims that key.
|
||||
*/
|
||||
describe(key: CredentialKey): AuthorizationEntry | undefined
|
||||
|
||||
/**
|
||||
* Withdraw the attempt running for a key, if any. Separate from the
|
||||
* request's own signal because a request/response transport answers a Cancel
|
||||
* button on a second call, with no handle on the first one's signal.
|
||||
* @param key - the credential record whose attempt should stop.
|
||||
*/
|
||||
cancel(key: CredentialKey): void
|
||||
|
||||
/**
|
||||
* Run one attempt to authorize a key, and report how it ended.
|
||||
*
|
||||
* One attempt per key at a time. A second caller is refused rather than
|
||||
* joined: the two would be prompting different humans through the same flow,
|
||||
* and the second would answer questions the first was asked.
|
||||
*
|
||||
* @param request - the key, the method, the surface, and the cancel signal.
|
||||
* @returns `authorized` once the flow's record is committed and observed,
|
||||
* or `cancelled` when the human or the caller withdrew.
|
||||
* @throws {AuthorizationError} code `NO_FLOW` when nothing claims the key,
|
||||
* `UNKNOWN_METHOD` when the named method is not one the flow offers,
|
||||
* `ALREADY_IN_FLIGHT` when an attempt is already running for the key, or
|
||||
* `NOT_COMMITTED` when the flow resolved without leaving a record behind.
|
||||
*/
|
||||
async begin(request: AuthorizationRequest): Promise<AuthorizationOutcome>
|
||||
```
|
||||
|
||||
Source: [`packages/credentials/authorization/src/index.ts:163`](../../packages/credentials/authorization/src/index.ts)
|
||||
|
||||
<a id="ctxcredentials--credentialprovider-abstract-seam"></a>
|
||||
|
||||
### `ctx.credentials` — `CredentialProvider` (abstract seam)
|
||||
@@ -151,6 +210,30 @@ abstract deleteRecord(key: CredentialKey): Promise<void>
|
||||
|
||||
Source: [`packages/credentials/credentials/src/index.ts:141`](../../packages/credentials/credentials/src/index.ts)
|
||||
|
||||
<a id="authorization-events"></a>
|
||||
|
||||
### `authorization/*` events
|
||||
|
||||
<a id="authorizationsettled--emit"></a>
|
||||
|
||||
#### `authorization/settled` — emit
|
||||
|
||||
One authorization attempt has finished and released its key. Fires for every terminal outcome, failures included, so a surface watching a key it did not start (a second browser tab) learns the attempt is over.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* One authorization attempt has finished and released its key. Fires for
|
||||
* every terminal outcome, failures included, so a surface watching a key it
|
||||
* did not start (a second browser tab) learns the attempt is over.
|
||||
* @mode emit
|
||||
* @param key - the credential record the finished attempt was authorizing.
|
||||
* @param settlement - how it ended, including the `failed` case its caller sees as a thrown error.
|
||||
*/
|
||||
'authorization/settled'(key: CredentialKey, settlement: AuthorizationSettlement): void
|
||||
```
|
||||
|
||||
Source: [`packages/credentials/authorization/src/index.ts:57`](../../packages/credentials/authorization/src/index.ts)
|
||||
|
||||
<a id="credentials-events"></a>
|
||||
|
||||
### `credentials/*` events
|
||||
|
||||
+1
-1
@@ -46,7 +46,7 @@ Groups hold `packages/<group>/<pkg>/`; names stay `@deepseek-ai/dsh-<pkg>`. **Gr
|
||||
| [`session/`](session/README.md) | Durable session data plane: persistence seam + JSONL/SQLite backends, projection seam, log-backed titles, session reporting | Product — stable API |
|
||||
| [`session-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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)。
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/credentials/authorization/README.md
|
||||
README.md: f73d2231456d0873cef7ea047fe537629a7552e0
|
||||
README.zh.md: ecceaa4f90a971deebe89e8dd6e51157aa0072a0
|
||||
@@ -0,0 +1,74 @@
|
||||
# dsh-authorization
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Authorization Service Definition (`ctx.authorization`). Some credentials cannot be configured, only obtained: getting one means a conversation with a human — open this page, paste that code, pick an account. This seam owns that conversation and the lifecycle around it, and never the protocol.
|
||||
|
||||
**A flow is a plugin's knowledge of how to get its own credential.** It is registered under the [`CredentialKey`](../credentials/README.md#two-key-spaces-two-questions) it writes, so a flow says which record it produces and, through that key's scope, which plugin answers for the format inside it. A second authorization protocol arrives as another flow, not as another seam.
|
||||
|
||||
**The flow owns the write.** `run()` resolving means the record is already committed through `ctx.credentials`; the seam confirms it and refuses a flow that resolved without one. Committing inside the flow is what lets a library that persists through its own store adapter stay the single writer instead of being copied back out and written twice.
|
||||
|
||||
**The interaction travels with the request, not a registry.** Whoever starts an authorization is the one who can talk to the human about it, so prompts reach exactly the surface that asked and a headless caller supplies an interaction that declines. There is no ambient provider to be absent, and no question about which of two open pages a prompt belongs to.
|
||||
|
||||
## Surface
|
||||
|
||||
```ts
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { AuthorizationSession } from '@deepseek-ai/dsh-authorization'
|
||||
import { credentialKey } from '@deepseek-ai/dsh-credentials'
|
||||
|
||||
declare const ctx: Context
|
||||
declare const exchange: (signal: AbortSignal) => Promise<void>
|
||||
|
||||
const key = credentialKey('llm-pi-ai', 'openai-codex')
|
||||
|
||||
const dispose = ctx.authorization.registerFlow({
|
||||
key,
|
||||
label: 'ChatGPT (Codex)',
|
||||
methods: [{ id: 'oauth', label: 'Sign in with ChatGPT' }],
|
||||
async run(session: AuthorizationSession) {
|
||||
session.notify({ message: 'Continue in your browser', url: 'https://auth.example/start' })
|
||||
const code = await session.prompt({ kind: 'text', message: 'Paste the code' })
|
||||
// Commits the record through ctx.credentials before resolving.
|
||||
await exchange(session.signal)
|
||||
void code
|
||||
},
|
||||
})
|
||||
|
||||
ctx.authorization.list() // [{ key, label, methods, inFlight }]
|
||||
ctx.authorization.describe(key) // the same entry, or undefined
|
||||
await ctx.authorization.begin({ // { status: 'authorized' | 'cancelled' }
|
||||
key,
|
||||
interaction: { notify: () => {}, prompt: () => Promise.reject(new Error('headless')) },
|
||||
})
|
||||
ctx.authorization.cancel(key) // withdraw whatever is running for the key
|
||||
dispose()
|
||||
```
|
||||
|
||||
One attempt per key at a time. A second caller is refused with `ALREADY_IN_FLIGHT` rather than joined, because the two would be prompting different humans through one flow and the second would be answering questions the first was asked. `inFlight` is on the entry so a surface renders the button disabled instead of discovering this by error.
|
||||
|
||||
`cancel(key)` exists beside the request's own signal because a request/response transport answers a Cancel button on a second call, holding no handle on the first one's signal. A flow whose registration is disposed mid-attempt is withdrawn the same way: its runner belongs to a plugin that is going away.
|
||||
|
||||
An attempt whose caller has already withdrawn never claims the key and never starts the flow — relying on each flow to check its signal before the first await would let one that does not hang holding the key. Validation still runs first, so a caller naming a key or method that does not exist hears about it whether or not it also gave up.
|
||||
|
||||
`authorization/settled (key, settlement)` fires after the key is released, for every terminal outcome. `settlement` adds `failed` to the two statuses `begin()` can return: a failure reaches its own caller as a thrown error, so the event stream is the only place a watcher that did not start the attempt can tell a refusal from a breakage.
|
||||
|
||||
## The interaction vocabulary
|
||||
|
||||
A notice is one-way and never carries a secret: a message, optionally the page the human must open and the code they must enter there. A prompt is a question the flow cannot answer — `text`, `secret`, or `select` — and `secret` differs from `text` only in presentation. A prompt carries its own `signal` so a flow that races a typed code against a browser callback can withdraw the losing question while the attempt continues; the request's signal withdraws the whole attempt instead.
|
||||
|
||||
The vocabulary is deliberately smaller than any one provider's: it describes what a surface must render, so a surface that renders one flow renders all of them.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as authorization is a configuration-time conversation with a human and no flow, notice, or prompt reaches a model request.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No invalidation; no authorization state enters a request prefix.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **No flow is resumable** — an attempt lives in the process that started it, so a browser reload during a login abandons it and the human starts over. Durable attempts need a store this seam does not have.
|
||||
- **Nothing revokes** — signing out is `ctx.credentials.deleteRecord(key)`, which forgets the local record without telling the issuer. A provider that needs a server-side revoke has no place to declare it yet.
|
||||
- **A key with no flow is inert** — the seam reports what is registered, so a record left by an uninstalled plugin can be deleted but not re-authorized. Recognizing that orphan is the caller's join, as it is for [`listRecords()`](../credentials/README.md#surface).
|
||||
@@ -0,0 +1,74 @@
|
||||
# dsh-authorization
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
授权 Service Definition(`ctx.authorization`)。有些凭据无法配置,只能获取:拿到它意味着与人对话——打开这个页面、粘贴那个码、选一个账号。本 seam 拥有这段对话及其生命周期,但从不拥有协议本身。
|
||||
|
||||
**flow 是某个插件"如何取得自己那份凭据"的知识。** 它以自己写入的 [`CredentialKey`](../credentials/README.md#two-key-spaces-two-questions) 注册,因此 flow 声明了自己产出哪条记录,并通过该键的 scope 声明由哪个插件为记录内部的格式负责。第二种授权协议以另一个 flow 的形式到来,而不是另一个 seam。
|
||||
|
||||
**写入由 flow 拥有。** `run()` 返回即表示记录已经通过 `ctx.credentials` 提交;seam 随后核实,并拒绝那些返回时没留下记录的 flow。让提交发生在 flow 内部,才能使一个通过自有 store 适配器持久化的库保持为唯一写入方,而不是把凭据复制出来再写第二遍。
|
||||
|
||||
**交互随请求传入,而非注册表。** 发起授权的一方才是能与人对话的一方,因此提示恰好抵达发问的那个界面,无头调用方则传入一个直接拒绝的交互实现。这样既不存在"环境提供方缺席"的问题,也不会出现某个提示该归两个已打开页面中哪一个的疑问。
|
||||
|
||||
## 接口
|
||||
|
||||
```ts
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { AuthorizationSession } from '@deepseek-ai/dsh-authorization'
|
||||
import { credentialKey } from '@deepseek-ai/dsh-credentials'
|
||||
|
||||
declare const ctx: Context
|
||||
declare const exchange: (signal: AbortSignal) => Promise<void>
|
||||
|
||||
const key = credentialKey('llm-pi-ai', 'openai-codex')
|
||||
|
||||
const dispose = ctx.authorization.registerFlow({
|
||||
key,
|
||||
label: 'ChatGPT (Codex)',
|
||||
methods: [{ id: 'oauth', label: 'Sign in with ChatGPT' }],
|
||||
async run(session: AuthorizationSession) {
|
||||
session.notify({ message: 'Continue in your browser', url: 'https://auth.example/start' })
|
||||
const code = await session.prompt({ kind: 'text', message: 'Paste the code' })
|
||||
// Commits the record through ctx.credentials before resolving.
|
||||
await exchange(session.signal)
|
||||
void code
|
||||
},
|
||||
})
|
||||
|
||||
ctx.authorization.list() // [{ key, label, methods, inFlight }]
|
||||
ctx.authorization.describe(key) // the same entry, or undefined
|
||||
await ctx.authorization.begin({ // { status: 'authorized' | 'cancelled' }
|
||||
key,
|
||||
interaction: { notify: () => {}, prompt: () => Promise.reject(new Error('headless')) },
|
||||
})
|
||||
ctx.authorization.cancel(key) // withdraw whatever is running for the key
|
||||
dispose()
|
||||
```
|
||||
|
||||
同一个键同时只允许一次尝试。第二个调用方会收到 `ALREADY_IN_FLIGHT` 拒绝而不是被并入:否则两者会通过同一个 flow 向不同的人发问,而第二个人回答的是问给第一个人的问题。`inFlight` 放在 entry 上,界面据此把按钮渲染为禁用,而不是靠报错才发现。
|
||||
|
||||
`cancel(key)` 与请求自带的 signal 并存,是因为请求/响应式传输要用第二次调用来响应"取消"按钮,而它拿不到第一次调用的 signal。注册在尝试进行中被 dispose 的 flow 也以同样方式撤销:它的执行体属于一个正在离开的插件。
|
||||
|
||||
调用方在发起前就已撤销的尝试,既不占用该键也不启动 flow——若指望每个 flow 都在首个 await 之前检查自己的 signal,那么没有检查的那个就会占着键一直挂起。校验仍然先执行,因此调用方给出的键或方法不存在时,无论它是否已经放弃都会收到报错。
|
||||
|
||||
`authorization/settled (key, settlement)` 在键释放之后触发,覆盖每一种终态。`settlement` 在 `begin()` 能返回的两种状态之外增加了 `failed`:失败以抛出的错误抵达其调用方,因此事件流是未发起该尝试的旁观者唯一能区分"被拒绝"与"出故障"的地方。
|
||||
|
||||
## 交互词汇
|
||||
|
||||
notice 是单向的,且从不携带机密:一条消息,以及可选的"人需要打开的页面"和"需要在该页面输入的码"。prompt 是 flow 无法自答的问题——`text`、`secret` 或 `select`——其中 `secret` 与 `text` 的差别仅在呈现方式。prompt 自带 `signal`,使得一个让手输码与浏览器回调赛跑的 flow 可以在尝试继续的同时撤下落败的那个问题;撤销整次尝试则用请求的 signal。
|
||||
|
||||
这套词汇刻意小于任何单个 provider 的词汇:它描述的是界面必须渲染什么,因此能渲染一个 flow 的界面就能渲染全部 flow。
|
||||
|
||||
## Model Experience
|
||||
|
||||
无,因为授权是配置期与人的对话,flow、notice 与 prompt 都不会抵达模型请求。
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
不失效;任何授权状态都不会进入请求前缀。
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **flow 不可恢复** —— 一次尝试只存活于发起它的进程中,因此登录途中刷新浏览器会丢弃它,人需要重来。可持久的尝试需要一个本 seam 并不具备的存储。
|
||||
- **没有吊销** —— 登出即 `ctx.credentials.deleteRecord(key)`,它只遗忘本地记录而不通知签发方。需要服务端吊销的 provider 目前无处声明这一点。
|
||||
- **没有 flow 的键是惰性的** —— seam 只报告已注册的内容,因此被卸载插件遗留的记录可以删除但无法重新授权。识别这种孤儿记录由调用方自行 join,与 [`listRecords()`](../credentials/README.md#surface) 的情况相同。
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-authorization",
|
||||
"description": "Authorization seam (ctx.authorization): plugin-owned flows that obtain a credential through a conversation with the human",
|
||||
"version": "0.0.1-rc.5",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/credentials/authorization"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./types": {
|
||||
"types": "./lib/types/types.d.ts",
|
||||
"default": "./lib/types/types.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-credentials": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-credentials": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* Service Definition for the authorization capability seam (`ctx.authorization`):
|
||||
* obtaining a credential nobody can supply from configuration alone, because
|
||||
* getting it requires a conversation with the human — open this page, paste
|
||||
* that code, pick an account.
|
||||
*
|
||||
* The seam owns the conversation and the lifecycle; it never owns the protocol.
|
||||
* A plugin that knows how to obtain its own credential registers a flow keyed
|
||||
* by the `CredentialKey` that flow writes, and the flow talks to whatever
|
||||
* surface started it through one neutral vocabulary of notices and prompts. So
|
||||
* a second authorization protocol arrives as another flow rather than as
|
||||
* another seam, and a surface that renders one flow renders all of them.
|
||||
*
|
||||
* ```ts
|
||||
* const dispose = ctx.authorization.registerFlow({
|
||||
* key: credentialKey('llm-pi-ai', 'openai-codex'),
|
||||
* label: 'ChatGPT (Codex)',
|
||||
* methods: [{ id: 'oauth', label: 'Sign in with ChatGPT' }],
|
||||
* async run(session) {
|
||||
* session.notify({ message: 'Continue in your browser', url })
|
||||
* await commitThroughCredentials(await exchange(session.signal))
|
||||
* },
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* @module @deepseek-ai/dsh-authorization
|
||||
*/
|
||||
|
||||
import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import type { CredentialKey } from '@deepseek-ai/dsh-credentials'
|
||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
import type {
|
||||
AuthorizationEntry, AuthorizationMethod, AuthorizationNotice, AuthorizationOutcome, AuthorizationPrompt,
|
||||
AuthorizationSettlement,
|
||||
} from './types.ts'
|
||||
|
||||
export type {
|
||||
AuthorizationEntry, AuthorizationMethod, AuthorizationNotice, AuthorizationOutcome, AuthorizationPrompt,
|
||||
AuthorizationPromptOption, AuthorizationSettlement, AuthorizationStatus,
|
||||
} from './types.ts'
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
authorization: AuthorizationService
|
||||
}
|
||||
|
||||
interface Events {
|
||||
/**
|
||||
* One authorization attempt has finished and released its key. Fires for
|
||||
* every terminal outcome, failures included, so a surface watching a key it
|
||||
* did not start (a second browser tab) learns the attempt is over.
|
||||
* @mode emit
|
||||
* @param key - the credential record the finished attempt was authorizing.
|
||||
* @param settlement - how it ended, including the `failed` case its caller sees as a thrown error.
|
||||
*/
|
||||
'authorization/settled'(key: CredentialKey, settlement: AuthorizationSettlement): void
|
||||
}
|
||||
}
|
||||
|
||||
/** Stable error taxonomy for authorization failures. */
|
||||
export class AuthorizationError extends HarnessError {
|
||||
constructor(message: string, code: string, options?: ErrorOptions) {
|
||||
super(message, code, options)
|
||||
this.name = 'AuthorizationError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What a running flow is given to talk to the human. Every member is scoped to
|
||||
* one attempt: the flow neither knows nor chooses which surface is listening.
|
||||
*/
|
||||
export interface AuthorizationSession {
|
||||
/** The method id the caller picked, always one this flow declared. */
|
||||
readonly method: string
|
||||
/** Aborted when the caller withdraws or `cancel()` is called for this key. */
|
||||
readonly signal: AbortSignal
|
||||
/**
|
||||
* Report progress, or tell the human what to do next. Fire-and-forget: a
|
||||
* surface that cannot render a notice must not stall the flow.
|
||||
* @param notice - the message, and any page or code it refers to.
|
||||
*/
|
||||
notify(notice: AuthorizationNotice): void
|
||||
/**
|
||||
* Ask the human a question the flow cannot answer for itself.
|
||||
* @param prompt - what to ask, and how it should be presented.
|
||||
* @returns what the human typed, or the chosen option's id.
|
||||
* @throws when the human declines, or the prompt's own signal withdraws it.
|
||||
*/
|
||||
prompt(prompt: AuthorizationPrompt): Promise<string>
|
||||
}
|
||||
|
||||
/**
|
||||
* A plugin's knowledge of how to obtain one credential. The flow owns the
|
||||
* write: `run()` resolving means the record for `key` is committed through
|
||||
* `ctx.credentials`, which the seam then confirms before reporting success.
|
||||
* Committing inside the flow is what lets a library that persists through its
|
||||
* own store adapter (pi-ai's `Models.login()`) stay the single writer instead
|
||||
* of being copied back out and written twice.
|
||||
*/
|
||||
export interface AuthorizationFlow {
|
||||
/** The credential record this flow writes. Its scope names the owning plugin. */
|
||||
readonly key: CredentialKey
|
||||
/** User-facing name of what is being authorized. */
|
||||
readonly label: string
|
||||
/**
|
||||
* The methods offered, most preferred first; a caller naming none gets the
|
||||
* first. Typed non-empty because a flow with nothing to run is a flow that
|
||||
* cannot be begun, and the type says so at the one place flows are written.
|
||||
*/
|
||||
readonly methods: readonly [AuthorizationMethod, ...AuthorizationMethod[]]
|
||||
/**
|
||||
* Run one attempt to obtain and commit the credential.
|
||||
* @param session - the chosen method, the cancellation signal, and the interaction callbacks.
|
||||
* @returns once the record is committed.
|
||||
* @throws when the attempt fails or the human declines.
|
||||
*/
|
||||
run(session: AuthorizationSession): Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* The surface half of one attempt. Supplied with the request rather than
|
||||
* registered, because the caller that starts an authorization is the one that
|
||||
* can talk to the human about it: prompts reach exactly the page that asked,
|
||||
* and a headless caller supplies an interaction that declines.
|
||||
*/
|
||||
export interface AuthorizationInteraction {
|
||||
/**
|
||||
* Render a notice from the running flow.
|
||||
* @param notice - the message, and any page or code it refers to.
|
||||
*/
|
||||
notify(notice: AuthorizationNotice): void
|
||||
/**
|
||||
* Put a question to the human and wait.
|
||||
* @param prompt - what to ask, and how it should be presented.
|
||||
* @returns the typed text, or the chosen option's id.
|
||||
* @throws when the human declines or the prompt is withdrawn.
|
||||
*/
|
||||
prompt(prompt: AuthorizationPrompt): Promise<string>
|
||||
}
|
||||
|
||||
/** One request to authorize a key. */
|
||||
export interface AuthorizationRequest {
|
||||
/** The credential record to authorize; a flow must be registered for it. */
|
||||
key: CredentialKey
|
||||
/** Which of the flow's methods to run. Defaults to the flow's first. */
|
||||
method?: string
|
||||
/** The surface that will render this attempt's notices and prompts. */
|
||||
interaction: AuthorizationInteraction
|
||||
/** Withdraws the whole attempt. */
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
/** One attempt in flight, with the handle that withdraws it. */
|
||||
interface InFlight {
|
||||
readonly controller: AbortController
|
||||
}
|
||||
|
||||
/**
|
||||
* `ctx.authorization`: a registry of credential-obtaining flows, one attempt at
|
||||
* a time per key.
|
||||
*/
|
||||
export class AuthorizationService extends Service {
|
||||
/** The commit this seam confirms is a credential-record write, so the store is required, not optional. */
|
||||
static inject = ['credentials']
|
||||
|
||||
private readonly flows = new Map<CredentialKey, AuthorizationFlow>()
|
||||
private readonly running = new Map<CredentialKey, InFlight>()
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'authorization')
|
||||
}
|
||||
|
||||
/**
|
||||
* Offer a way to obtain one credential. One flow per key: two plugins
|
||||
* claiming the same key would each write a record in their own format, and
|
||||
* whichever ran last would leave the other reading a payload it cannot parse.
|
||||
*
|
||||
* @param flow - the key it writes, its label, its methods, and its runner.
|
||||
* @returns Disposer that withdraws this flow.
|
||||
* @throws {AuthorizationError} code `DUPLICATE_FLOW` when the key is already claimed.
|
||||
*/
|
||||
registerFlow(flow: AuthorizationFlow): () => void {
|
||||
const dispose = this.ctx.effect(function* (this: AuthorizationService) {
|
||||
if (this.flows.has(flow.key)) {
|
||||
throw new AuthorizationError(
|
||||
`an authorization flow for "${flow.key}" is already registered`, 'DUPLICATE_FLOW')
|
||||
}
|
||||
this.flows.set(flow.key, flow)
|
||||
yield () => {
|
||||
this.flows.delete(flow.key)
|
||||
// A flow leaving mid-attempt takes its attempt with it: the runner
|
||||
// belongs to a plugin that is going away, so letting it keep prompting
|
||||
// would outlive the fiber that can answer for it.
|
||||
this.running.get(flow.key)?.controller.abort()
|
||||
}
|
||||
}.bind(this), 'authorization.registerFlow()')
|
||||
return () => void dispose()
|
||||
}
|
||||
|
||||
/**
|
||||
* Every registered flow, for a surface listing what can be authorized.
|
||||
* @returns one entry per flow, in registration order.
|
||||
*/
|
||||
list(): readonly AuthorizationEntry[] {
|
||||
return [...this.flows.values()].map(flow => this.entry(flow))
|
||||
}
|
||||
|
||||
/**
|
||||
* One registered flow.
|
||||
* @param key - the credential record to ask about.
|
||||
* @returns the entry, or undefined when no flow claims that key.
|
||||
*/
|
||||
describe(key: CredentialKey): AuthorizationEntry | undefined {
|
||||
const flow = this.flows.get(key)
|
||||
return flow === undefined ? undefined : this.entry(flow)
|
||||
}
|
||||
|
||||
/** The public view of one registered flow. */
|
||||
private entry(flow: AuthorizationFlow): AuthorizationEntry {
|
||||
return {
|
||||
key: flow.key,
|
||||
label: flow.label,
|
||||
methods: flow.methods,
|
||||
inFlight: this.running.has(flow.key),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Withdraw the attempt running for a key, if any. Separate from the
|
||||
* request's own signal because a request/response transport answers a Cancel
|
||||
* button on a second call, with no handle on the first one's signal.
|
||||
* @param key - the credential record whose attempt should stop.
|
||||
*/
|
||||
cancel(key: CredentialKey): void {
|
||||
this.running.get(key)?.controller.abort()
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one attempt to authorize a key, and report how it ended.
|
||||
*
|
||||
* One attempt per key at a time. A second caller is refused rather than
|
||||
* joined: the two would be prompting different humans through the same flow,
|
||||
* and the second would answer questions the first was asked.
|
||||
*
|
||||
* @param request - the key, the method, the surface, and the cancel signal.
|
||||
* @returns `authorized` once the flow's record is committed and observed,
|
||||
* or `cancelled` when the human or the caller withdrew.
|
||||
* @throws {AuthorizationError} code `NO_FLOW` when nothing claims the key,
|
||||
* `UNKNOWN_METHOD` when the named method is not one the flow offers,
|
||||
* `ALREADY_IN_FLIGHT` when an attempt is already running for the key, or
|
||||
* `NOT_COMMITTED` when the flow resolved without leaving a record behind.
|
||||
*/
|
||||
async begin(request: AuthorizationRequest): Promise<AuthorizationOutcome> {
|
||||
const { key } = request
|
||||
const flow = this.flows.get(key)
|
||||
if (flow === undefined) {
|
||||
throw new AuthorizationError(`no authorization flow is registered for "${key}"`, 'NO_FLOW')
|
||||
}
|
||||
const method = request.method ?? flow.methods[0].id
|
||||
if (!flow.methods.some(candidate => candidate.id === method)) {
|
||||
throw new AuthorizationError(
|
||||
`authorization flow for "${key}" offers no method "${method}"`, 'UNKNOWN_METHOD')
|
||||
}
|
||||
if (this.running.has(key)) {
|
||||
throw new AuthorizationError(
|
||||
`an authorization attempt for "${key}" is already running`, 'ALREADY_IN_FLIGHT')
|
||||
}
|
||||
// Withdrawn before it began: never claim the slot and never run the flow.
|
||||
// Handing an aborted signal to `run()` would rely on every flow checking it
|
||||
// before its first await, and one that does not would hang holding the key.
|
||||
// Validation still runs first, so a caller naming a key or method that does
|
||||
// not exist hears about it whether or not it also gave up.
|
||||
if (request.signal?.aborted === true) return { status: 'cancelled' }
|
||||
const controller = new AbortController()
|
||||
const withdraw = (): void => { controller.abort(request.signal?.reason) }
|
||||
request.signal?.addEventListener('abort', withdraw, { once: true })
|
||||
this.running.set(key, { controller })
|
||||
let settlement: AuthorizationSettlement = 'failed'
|
||||
try {
|
||||
const outcome = await this.attempt(flow, method, controller.signal, request.interaction)
|
||||
settlement = outcome.status
|
||||
return outcome
|
||||
} finally {
|
||||
request.signal?.removeEventListener('abort', withdraw)
|
||||
this.running.delete(key)
|
||||
// After the slot is released, so a listener that reacts by starting the
|
||||
// next attempt is not refused by the one that just finished.
|
||||
this.ctx.emit('authorization/settled', key, settlement)
|
||||
}
|
||||
}
|
||||
|
||||
/** Run the flow, then hold it to its half of the commit contract. */
|
||||
private async attempt(
|
||||
flow: AuthorizationFlow,
|
||||
method: string,
|
||||
signal: AbortSignal,
|
||||
interaction: AuthorizationInteraction,
|
||||
): Promise<AuthorizationOutcome> {
|
||||
try {
|
||||
await flow.run({
|
||||
method,
|
||||
signal,
|
||||
notify: (notice) => { interaction.notify(notice) },
|
||||
prompt: prompt => interaction.prompt(prompt),
|
||||
})
|
||||
} catch (error) {
|
||||
// A withdrawn attempt is an outcome, not a failure: the human said no, or
|
||||
// closed the page. Anything else is the flow failing and belongs to the
|
||||
// caller, cause chain intact.
|
||||
if (signal.aborted) return { status: 'cancelled' }
|
||||
throw error
|
||||
}
|
||||
const stored = await this.ctx.credentials.describeRecord(flow.key)
|
||||
if (!stored.configured) {
|
||||
throw new AuthorizationError(
|
||||
`authorization flow for "${flow.key}" resolved without committing a credential record`,
|
||||
'NOT_COMMITTED')
|
||||
}
|
||||
return { status: 'authorized' }
|
||||
}
|
||||
}
|
||||
|
||||
export default AuthorizationService
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-authorization`.
|
||||
* @module @deepseek-ai/dsh-authorization/invariant
|
||||
*/
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-authorization'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'authorization-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* Install the single-flight release contract: `authorization/settled` names a
|
||||
* finished attempt, and the seam admits one attempt per key, so the key must
|
||||
* already be free when the event fires. A slot still held at settlement is
|
||||
* unrecoverable — every later `begin()` for that key is refused as
|
||||
* `ALREADY_IN_FLIGHT` until the process restarts — and it is invisible from the
|
||||
* outside, because a wedged key looks exactly like a busy one.
|
||||
*/
|
||||
const install: InvariantInstaller = (ctx: Context, fail: InvariantFailure) => {
|
||||
ctx.on('authorization/settled', (key) => {
|
||||
const authorization = ctx.get('authorization')
|
||||
if (authorization === undefined) {
|
||||
fail(`authorization/settled for "${key}" emitted without a live authorization service`)
|
||||
return
|
||||
}
|
||||
// A flow withdrawn during its own attempt settles with nothing left to
|
||||
// describe, which is the disposer's documented behavior rather than a leak.
|
||||
if (authorization.describe(key)?.inFlight === true) {
|
||||
fail(`authorization/settled for "${key}" left the key in flight, wedging every later attempt`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Wire-safe authorization types, free of cordis/service imports so browser type
|
||||
* chains (apiproxy api → client) can consume them without loading this
|
||||
* package's Context augmentation.
|
||||
* @module @deepseek-ai/dsh-authorization/types
|
||||
*/
|
||||
|
||||
import type { CredentialKey } from '@deepseek-ai/dsh-credentials/types'
|
||||
|
||||
/** One way a flow can obtain its credential, named by the flow that offers it. */
|
||||
export interface AuthorizationMethod {
|
||||
/** Flow-owned identifier, echoed back when a caller picks this method. */
|
||||
id: string
|
||||
/** User-facing label for a picker. */
|
||||
label: string
|
||||
}
|
||||
|
||||
/** A running flow's report to whoever is watching it. Never carries a secret. */
|
||||
export interface AuthorizationNotice {
|
||||
/** What is happening, or what the human must do next. */
|
||||
message: string
|
||||
/** A page the human must open to continue. */
|
||||
url?: string
|
||||
/** A short code the human must enter on that page. */
|
||||
code?: string
|
||||
}
|
||||
|
||||
/** One choice offered by a `select` prompt. */
|
||||
export interface AuthorizationPromptOption {
|
||||
/** Value returned when this option is chosen. */
|
||||
id: string
|
||||
/** User-facing label. */
|
||||
label: string
|
||||
/** Optional extra context rendered by capable surfaces. */
|
||||
description?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A question a flow must have answered before it can continue. `secret` differs
|
||||
* from `text` only in presentation — a surface masks it and keeps it out of
|
||||
* logs — and `select` answers with the chosen option's `id`.
|
||||
*/
|
||||
export type AuthorizationPrompt = {
|
||||
/**
|
||||
* Withdraws this prompt alone, leaving the flow running. A flow that races a
|
||||
* typed code against a browser callback aborts the losing prompt here; the
|
||||
* whole authorization is cancelled through the request's signal instead.
|
||||
*/
|
||||
signal?: AbortSignal
|
||||
} & ({
|
||||
kind: 'text'
|
||||
message: string
|
||||
placeholder?: string
|
||||
} | {
|
||||
kind: 'secret'
|
||||
message: string
|
||||
placeholder?: string
|
||||
} | {
|
||||
kind: 'select'
|
||||
message: string
|
||||
options: readonly AuthorizationPromptOption[]
|
||||
})
|
||||
|
||||
/** How one authorization attempt ended, as its own caller sees it. */
|
||||
export type AuthorizationStatus = 'authorized' | 'cancelled'
|
||||
|
||||
/**
|
||||
* How one attempt ended, as an onlooker sees it. A failure reaches its caller
|
||||
* as a thrown error rather than an outcome, so `failed` exists only here — on
|
||||
* the event stream, where a watcher that did not start the attempt has no
|
||||
* other way to tell a refusal from a breakage.
|
||||
*/
|
||||
export type AuthorizationSettlement = AuthorizationStatus | 'failed'
|
||||
|
||||
/** The result of one `begin()` attempt. */
|
||||
export interface AuthorizationOutcome {
|
||||
/** `authorized` once the record is committed and observed; `cancelled` when the human or caller withdrew. */
|
||||
status: AuthorizationStatus
|
||||
}
|
||||
|
||||
/** A registered flow as a surface sees it: what it authorizes and whether it is busy. */
|
||||
export interface AuthorizationEntry {
|
||||
/** The credential record this flow writes. */
|
||||
key: CredentialKey
|
||||
/** User-facing name of what is being authorized. */
|
||||
label: string
|
||||
/** The methods this flow offers, most preferred first. */
|
||||
methods: readonly AuthorizationMethod[]
|
||||
/** Whether an attempt for this key is running right now. */
|
||||
inFlight: boolean
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { credentialKey } from '@deepseek-ai/dsh-credentials'
|
||||
import AuthorizationService, {
|
||||
type AuthorizationFlow,
|
||||
type AuthorizationInteraction,
|
||||
type AuthorizationSession,
|
||||
} from '@deepseek-ai/dsh-authorization'
|
||||
import { MemoryCredentials } from './memory.ts'
|
||||
|
||||
const KEY = credentialKey('llm-pi-ai', 'openai-codex')
|
||||
const OTHER = credentialKey('llm-pi-ai', 'anthropic')
|
||||
|
||||
/** A context with the record store the seam confirms commits against. */
|
||||
async function harness(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(MemoryCredentials)
|
||||
await ctx.plugin(AuthorizationService)
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** An interaction that answers every prompt with the same string. */
|
||||
function surface(answer = 'typed'): AuthorizationInteraction & {
|
||||
notices: unknown[]
|
||||
prompts: unknown[]
|
||||
} {
|
||||
const notices: unknown[] = []
|
||||
const prompts: unknown[] = []
|
||||
return {
|
||||
notices,
|
||||
prompts,
|
||||
notify: (notice) => { notices.push(notice) },
|
||||
prompt: (prompt) => {
|
||||
prompts.push(prompt)
|
||||
return Promise.resolve(answer)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** A flow that commits `key` through the record store and then resolves. */
|
||||
function committingFlow(
|
||||
ctx: Context,
|
||||
key = KEY,
|
||||
run?: (session: AuthorizationSession) => Promise<void>,
|
||||
): AuthorizationFlow {
|
||||
return {
|
||||
key,
|
||||
label: 'ChatGPT (Codex)',
|
||||
methods: [{ id: 'oauth', label: 'Sign in with ChatGPT' }, { id: 'api-key', label: 'Paste a key' }],
|
||||
async run(session) {
|
||||
await run?.(session)
|
||||
await ctx.credentials.modifyRecord(key, () =>
|
||||
Promise.resolve({ kind: 'grant', payload: { token: 'granted' } }))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('AuthorizationService registry', () => {
|
||||
it('lists a registered flow and drops it when the registration is disposed', async () => {
|
||||
const ctx = await harness()
|
||||
|
||||
const dispose = ctx.authorization.registerFlow(committingFlow(ctx))
|
||||
|
||||
expect(ctx.authorization.list()).toEqual([{
|
||||
key: KEY,
|
||||
label: 'ChatGPT (Codex)',
|
||||
methods: [{ id: 'oauth', label: 'Sign in with ChatGPT' }, { id: 'api-key', label: 'Paste a key' }],
|
||||
inFlight: false,
|
||||
}])
|
||||
expect(ctx.authorization.describe(KEY)?.label).toBe('ChatGPT (Codex)')
|
||||
expect(ctx.authorization.describe(OTHER)).toBeUndefined()
|
||||
|
||||
dispose()
|
||||
|
||||
expect(ctx.authorization.list()).toEqual([])
|
||||
expect(ctx.authorization.describe(KEY)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('refuses a second flow for the same key', async () => {
|
||||
const ctx = await harness()
|
||||
ctx.authorization.registerFlow(committingFlow(ctx))
|
||||
|
||||
expect(() => ctx.authorization.registerFlow(committingFlow(ctx)))
|
||||
.toThrow(/already registered/)
|
||||
})
|
||||
|
||||
it('withdraws an attempt still running when its flow leaves', async () => {
|
||||
const ctx = await harness()
|
||||
let started: (() => void) | undefined
|
||||
const running = new Promise<void>((resolve) => {
|
||||
started = resolve
|
||||
})
|
||||
const dispose = ctx.authorization.registerFlow(committingFlow(ctx, KEY, session =>
|
||||
new Promise((_resolve, reject) => {
|
||||
started?.()
|
||||
session.signal.addEventListener('abort', () => { reject(new Error('withdrawn')) }, { once: true })
|
||||
})))
|
||||
|
||||
const attempt = ctx.authorization.begin({ key: KEY, interaction: surface() })
|
||||
await running
|
||||
dispose()
|
||||
|
||||
await expect(attempt).resolves.toEqual({ status: 'cancelled' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('AuthorizationService.begin', () => {
|
||||
it('runs the flow, confirms the committed record, and reports the settlement', async () => {
|
||||
const ctx = await harness()
|
||||
ctx.authorization.registerFlow(committingFlow(ctx))
|
||||
const settled = vi.fn()
|
||||
ctx.on('authorization/settled', settled)
|
||||
|
||||
await expect(ctx.authorization.begin({ key: KEY, interaction: surface() }))
|
||||
.resolves.toEqual({ status: 'authorized' })
|
||||
|
||||
expect(await ctx.credentials.readRecord(KEY)).toEqual({ kind: 'grant', payload: { token: 'granted' } })
|
||||
expect(settled).toHaveBeenCalledWith(KEY, 'authorized')
|
||||
})
|
||||
|
||||
it('runs the flow first method when the caller names none, and the named one when it does', async () => {
|
||||
const ctx = await harness()
|
||||
const seen: string[] = []
|
||||
ctx.authorization.registerFlow(committingFlow(ctx, KEY, (session) => {
|
||||
seen.push(session.method)
|
||||
return Promise.resolve()
|
||||
}))
|
||||
|
||||
await ctx.authorization.begin({ key: KEY, interaction: surface() })
|
||||
await ctx.authorization.begin({ key: KEY, method: 'api-key', interaction: surface() })
|
||||
|
||||
expect(seen).toEqual(['oauth', 'api-key'])
|
||||
})
|
||||
|
||||
it('carries notices and prompts between the flow and the calling surface', async () => {
|
||||
const ctx = await harness()
|
||||
const answers: string[] = []
|
||||
ctx.authorization.registerFlow(committingFlow(ctx, KEY, async (session) => {
|
||||
session.notify({ message: 'Continue in your browser', url: 'https://auth.example/start' })
|
||||
answers.push(await session.prompt({ kind: 'text', message: 'Paste the code' }))
|
||||
}))
|
||||
const ui = surface('code-123')
|
||||
|
||||
await ctx.authorization.begin({ key: KEY, interaction: ui })
|
||||
|
||||
expect(ui.notices).toEqual([{ message: 'Continue in your browser', url: 'https://auth.example/start' }])
|
||||
expect(ui.prompts).toEqual([{ kind: 'text', message: 'Paste the code' }])
|
||||
expect(answers).toEqual(['code-123'])
|
||||
})
|
||||
|
||||
it('refuses a key no flow claims', async () => {
|
||||
const ctx = await harness()
|
||||
|
||||
await expect(ctx.authorization.begin({ key: KEY, interaction: surface() }))
|
||||
.rejects.toThrow(/no authorization flow is registered/)
|
||||
})
|
||||
|
||||
it('refuses a method the flow does not offer', async () => {
|
||||
const ctx = await harness()
|
||||
ctx.authorization.registerFlow(committingFlow(ctx))
|
||||
|
||||
await expect(ctx.authorization.begin({ key: KEY, method: 'device', interaction: surface() }))
|
||||
.rejects.toThrow(/offers no method "device"/)
|
||||
})
|
||||
|
||||
it('refuses a second attempt while one is running, and admits one after it settles', async () => {
|
||||
const ctx = await harness()
|
||||
// Only the first attempt blocks; the later ones must be free to complete,
|
||||
// which is what shows the key was released rather than merely idle-looking.
|
||||
const held = Promise.withResolvers<undefined>()
|
||||
const started = Promise.withResolvers<undefined>()
|
||||
let first = true
|
||||
ctx.authorization.registerFlow(committingFlow(ctx, KEY, () => {
|
||||
if (!first) return Promise.resolve()
|
||||
first = false
|
||||
started.resolve(undefined)
|
||||
return held.promise
|
||||
}))
|
||||
|
||||
const attempt = ctx.authorization.begin({ key: KEY, interaction: surface() })
|
||||
await started.promise
|
||||
expect(ctx.authorization.describe(KEY)?.inFlight).toBe(true)
|
||||
await expect(ctx.authorization.begin({ key: KEY, interaction: surface() }))
|
||||
.rejects.toThrow(/already running/)
|
||||
|
||||
held.resolve(undefined)
|
||||
await expect(attempt).resolves.toEqual({ status: 'authorized' })
|
||||
expect(ctx.authorization.describe(KEY)?.inFlight).toBe(false)
|
||||
await expect(ctx.authorization.begin({ key: KEY, interaction: surface() }))
|
||||
.resolves.toEqual({ status: 'authorized' })
|
||||
})
|
||||
|
||||
it('never starts a flow whose caller withdrew before begin', async () => {
|
||||
const ctx = await harness()
|
||||
const ran = vi.fn()
|
||||
const settled = vi.fn()
|
||||
ctx.on('authorization/settled', settled)
|
||||
ctx.authorization.registerFlow(committingFlow(ctx, KEY, () => {
|
||||
ran()
|
||||
return new Promise(() => {})
|
||||
}))
|
||||
|
||||
await expect(ctx.authorization.begin({
|
||||
key: KEY,
|
||||
interaction: surface(),
|
||||
signal: AbortSignal.abort(),
|
||||
})).resolves.toEqual({ status: 'cancelled' })
|
||||
|
||||
expect(ran).not.toHaveBeenCalled()
|
||||
// Nothing occupied the key, so nothing settled on it either.
|
||||
expect(settled).not.toHaveBeenCalled()
|
||||
expect(ctx.authorization.describe(KEY)?.inFlight).toBe(false)
|
||||
})
|
||||
|
||||
it('still reports an unknown method to a caller that already withdrew', async () => {
|
||||
const ctx = await harness()
|
||||
ctx.authorization.registerFlow(committingFlow(ctx))
|
||||
|
||||
await expect(ctx.authorization.begin({
|
||||
key: KEY,
|
||||
method: 'device',
|
||||
interaction: surface(),
|
||||
signal: AbortSignal.abort(),
|
||||
})).rejects.toThrow(/offers no method "device"/)
|
||||
})
|
||||
|
||||
it('reports a caller that withdraws mid-flight as cancelled', async () => {
|
||||
const ctx = await harness()
|
||||
const controller = new AbortController()
|
||||
ctx.authorization.registerFlow(committingFlow(ctx, KEY, session =>
|
||||
new Promise((_resolve, reject) => {
|
||||
session.signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
|
||||
controller.abort()
|
||||
})))
|
||||
|
||||
await expect(ctx.authorization.begin({ key: KEY, interaction: surface(), signal: controller.signal }))
|
||||
.resolves.toEqual({ status: 'cancelled' })
|
||||
})
|
||||
|
||||
it('withdraws a running attempt through cancel(), and ignores cancel() for an idle key', async () => {
|
||||
const ctx = await harness()
|
||||
const started = Promise.withResolvers<undefined>()
|
||||
ctx.authorization.registerFlow(committingFlow(ctx, KEY, session =>
|
||||
new Promise((_resolve, reject) => {
|
||||
session.signal.addEventListener('abort', () => { reject(new Error('cancelled')) }, { once: true })
|
||||
started.resolve(undefined)
|
||||
})))
|
||||
ctx.authorization.cancel(OTHER)
|
||||
|
||||
const attempt = ctx.authorization.begin({ key: KEY, interaction: surface() })
|
||||
await started.promise
|
||||
ctx.authorization.cancel(KEY)
|
||||
|
||||
await expect(attempt).resolves.toEqual({ status: 'cancelled' })
|
||||
})
|
||||
|
||||
it('propagates a flow failure to its caller and settles the key as failed', async () => {
|
||||
const ctx = await harness()
|
||||
ctx.authorization.registerFlow(committingFlow(ctx, KEY, () =>
|
||||
Promise.reject(new Error('the token endpoint said no'))))
|
||||
const settled = vi.fn()
|
||||
ctx.on('authorization/settled', settled)
|
||||
|
||||
await expect(ctx.authorization.begin({ key: KEY, interaction: surface() }))
|
||||
.rejects.toThrow('the token endpoint said no')
|
||||
|
||||
expect(settled).toHaveBeenCalledWith(KEY, 'failed')
|
||||
expect(ctx.authorization.describe(KEY)?.inFlight).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses a flow that resolves without committing its record', async () => {
|
||||
const ctx = await harness()
|
||||
ctx.authorization.registerFlow({
|
||||
key: KEY,
|
||||
label: 'Forgetful',
|
||||
methods: [{ id: 'oauth', label: 'Sign in' }],
|
||||
run: () => Promise.resolve(),
|
||||
})
|
||||
|
||||
await expect(ctx.authorization.begin({ key: KEY, interaction: surface() }))
|
||||
.rejects.toThrow(/resolved without committing a credential record/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { credentialKey } from '@deepseek-ai/dsh-credentials'
|
||||
import InvariantRegistry from '@deepseek-ai/dsh-invariants'
|
||||
import AuthorizationService from '@deepseek-ai/dsh-authorization'
|
||||
import * as AuthorizationInvariant from '../src/invariant.ts'
|
||||
import { MemoryCredentials } from './memory.ts'
|
||||
|
||||
const KEY = credentialKey('llm-pi-ai', 'openai-codex')
|
||||
|
||||
describe('authorization invariant companion', () => {
|
||||
it('accepts an attempt that released its key before settling', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(InvariantRegistry)
|
||||
await ctx.plugin(AuthorizationInvariant)
|
||||
await ctx.plugin(MemoryCredentials)
|
||||
await ctx.plugin(AuthorizationService)
|
||||
ctx.authorization.registerFlow({
|
||||
key: KEY,
|
||||
label: 'ChatGPT (Codex)',
|
||||
methods: [{ id: 'oauth', label: 'Sign in' }],
|
||||
run: () => ctx.credentials
|
||||
.modifyRecord(KEY, () => Promise.resolve({ kind: 'grant', payload: {} }))
|
||||
.then(() => undefined),
|
||||
})
|
||||
|
||||
await expect(ctx.authorization.begin({
|
||||
key: KEY,
|
||||
interaction: { notify: () => {}, prompt: () => Promise.reject(new Error('unused')) },
|
||||
})).resolves.toEqual({ status: 'authorized' })
|
||||
})
|
||||
|
||||
it('fails a settlement that left its key in flight', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(InvariantRegistry)
|
||||
await ctx.plugin(AuthorizationInvariant)
|
||||
await ctx.plugin(MemoryCredentials)
|
||||
await ctx.plugin(AuthorizationService)
|
||||
const started = Promise.withResolvers<undefined>()
|
||||
ctx.authorization.registerFlow({
|
||||
key: KEY,
|
||||
label: 'ChatGPT (Codex)',
|
||||
methods: [{ id: 'oauth', label: 'Sign in' }],
|
||||
run: () => {
|
||||
started.resolve(undefined)
|
||||
return new Promise(() => {})
|
||||
},
|
||||
})
|
||||
void ctx.authorization.begin({
|
||||
key: KEY,
|
||||
interaction: { notify: () => {}, prompt: () => Promise.reject(new Error('unused')) },
|
||||
})
|
||||
await started.promise
|
||||
|
||||
expect(() => { ctx.emit('authorization/settled', KEY, 'authorized') })
|
||||
.toThrow(/left the key in flight/)
|
||||
})
|
||||
|
||||
it('fails a settlement emitted without a live service', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(InvariantRegistry)
|
||||
await ctx.plugin(AuthorizationInvariant)
|
||||
|
||||
expect(() => { ctx.emit('authorization/settled', KEY, 'cancelled') })
|
||||
.toThrow(/without a live authorization service/)
|
||||
})
|
||||
|
||||
it('accepts a settlement whose flow left during its own attempt', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(InvariantRegistry)
|
||||
await ctx.plugin(AuthorizationInvariant)
|
||||
await ctx.plugin(MemoryCredentials)
|
||||
await ctx.plugin(AuthorizationService)
|
||||
|
||||
expect(() => { ctx.emit('authorization/settled', KEY, 'cancelled') }).not.toThrow()
|
||||
})
|
||||
|
||||
it('reserves the package name against duplicate registration', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(InvariantRegistry)
|
||||
await ctx.plugin(AuthorizationInvariant)
|
||||
|
||||
expect(() => {
|
||||
ctx.invariants.register('@deepseek-ai/dsh-authorization', () => {})
|
||||
}).toThrow(/already registered/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
import { CredentialProvider } from '@deepseek-ai/dsh-credentials'
|
||||
import type {
|
||||
CredentialInfo,
|
||||
CredentialKey,
|
||||
CredentialRecord,
|
||||
CredentialRecordEntry,
|
||||
CredentialRecordInfo,
|
||||
CredentialRef,
|
||||
ResolvedCredential,
|
||||
} from '@deepseek-ai/dsh-credentials'
|
||||
|
||||
/**
|
||||
* In-memory credentials provider for the authorization suite. Only the record
|
||||
* half is exercised — the seam's whole interest in this service is whether a
|
||||
* flow left a record behind — so the reference half answers "nothing stored".
|
||||
*/
|
||||
export class MemoryCredentials extends CredentialProvider {
|
||||
private readonly records = new Map<CredentialKey, CredentialRecord>()
|
||||
|
||||
override resolve(_ref: CredentialRef): Promise<ResolvedCredential | undefined> {
|
||||
return Promise.resolve(undefined)
|
||||
}
|
||||
|
||||
override describe(_ref: CredentialRef): Promise<CredentialInfo> {
|
||||
return Promise.resolve({ configured: false, writable: true })
|
||||
}
|
||||
|
||||
override set(_ref: CredentialRef, _value: string): Promise<void> {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
override unset(_ref: CredentialRef): Promise<void> {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
override readRecord(key: CredentialKey): Promise<CredentialRecord | undefined> {
|
||||
return Promise.resolve(this.records.get(key))
|
||||
}
|
||||
|
||||
override describeRecord(key: CredentialKey): Promise<CredentialRecordInfo> {
|
||||
const stored = this.records.get(key)
|
||||
return Promise.resolve(stored === undefined
|
||||
? { configured: false, writable: true }
|
||||
: { configured: true, kind: stored.kind, writable: true })
|
||||
}
|
||||
|
||||
override listRecords(): Promise<readonly CredentialRecordEntry[]> {
|
||||
return Promise.resolve([...this.records].map(([key, record]) => ({ key, kind: record.kind })))
|
||||
}
|
||||
|
||||
override async modifyRecord(
|
||||
key: CredentialKey,
|
||||
mutate: (current: CredentialRecord | undefined) => Promise<CredentialRecord | undefined>,
|
||||
): Promise<CredentialRecord | undefined> {
|
||||
const current = this.records.get(key)
|
||||
const next = await mutate(current)
|
||||
if (next === undefined) return current
|
||||
this.records.set(key, next)
|
||||
this.ctx.emit('credentials/record-updated', key)
|
||||
return next
|
||||
}
|
||||
|
||||
override deleteRecord(key: CredentialKey): Promise<void> {
|
||||
if (this.records.delete(key)) this.ctx.emit('credentials/record-updated', key)
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../credentials"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../runtime-diagnostics/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -457,6 +457,44 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'authorization',
|
||||
summary: '`ctx.authorization`: a registry of credential-obtaining flows, one attempt at a time per key.',
|
||||
description: '`ctx.authorization`: a registry of credential-obtaining flows, one attempt at a time per key.',
|
||||
methods: [
|
||||
{
|
||||
signature: 'registerFlow(flow: AuthorizationFlow): () => void',
|
||||
description: 'Offer a way to obtain one credential. One flow per key: two plugins claiming the same key would each write a record in their own format, and whichever ran last would leave the other reading a payload it cannot parse.',
|
||||
parameters: [{ name: 'flow', description: 'the key it writes, its label, its methods, and its runner.' }],
|
||||
returns: 'Disposer that withdraws this flow.',
|
||||
throws: ['{AuthorizationError} code `DUPLICATE_FLOW` when the key is already claimed.'],
|
||||
},
|
||||
{
|
||||
signature: 'list(): readonly AuthorizationEntry[]',
|
||||
description: 'Every registered flow, for a surface listing what can be authorized.',
|
||||
parameters: [],
|
||||
returns: 'one entry per flow, in registration order.',
|
||||
},
|
||||
{
|
||||
signature: 'describe(key: CredentialKey): AuthorizationEntry | undefined',
|
||||
description: 'One registered flow.',
|
||||
parameters: [{ name: 'key', description: 'the credential record to ask about.' }],
|
||||
returns: 'the entry, or undefined when no flow claims that key.',
|
||||
},
|
||||
{
|
||||
signature: 'cancel(key: CredentialKey): void',
|
||||
description: 'Withdraw the attempt running for a key, if any. Separate from the request\'s own signal because a request/response transport answers a Cancel button on a second call, with no handle on the first one\'s signal.',
|
||||
parameters: [{ name: 'key', description: 'the credential record whose attempt should stop.' }],
|
||||
},
|
||||
{
|
||||
signature: 'async begin(request: AuthorizationRequest): Promise<AuthorizationOutcome>',
|
||||
description: 'Run one attempt to authorize a key, and report how it ended.\n\nOne attempt per key at a time. A second caller is refused rather than joined: the two would be prompting different humans through the same flow, and the second would answer questions the first was asked.',
|
||||
parameters: [{ name: 'request', description: 'the key, the method, the surface, and the cancel signal.' }],
|
||||
returns: '`authorized` once the flow\'s record is committed and observed, or `cancelled` when the human or the caller withdrew.',
|
||||
throws: ['{AuthorizationError} code `NO_FLOW` when nothing claims the key, `UNKNOWN_METHOD` when the named method is not one the flow offers, `ALREADY_IN_FLIGHT` when an attempt is already running for the key, or `NOT_COMMITTED` when the flow resolved without leaving a record behind.'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'clientModules',
|
||||
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<void>;\n}',
|
||||
},
|
||||
{
|
||||
name: 'AuthorizationInteraction',
|
||||
declaration: 'export interface AuthorizationInteraction {\n notify(notice: AuthorizationNotice): void;\n prompt(prompt: AuthorizationPrompt): Promise<string>;\n}',
|
||||
},
|
||||
{
|
||||
name: 'AuthorizationMethod',
|
||||
declaration: 'export interface AuthorizationMethod {\n id: string;\n label: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'AuthorizationNotice',
|
||||
declaration: 'export interface AuthorizationNotice {\n message: string;\n url?: string;\n code?: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'AuthorizationOutcome',
|
||||
declaration: 'export interface AuthorizationOutcome {\n status: AuthorizationStatus;\n}',
|
||||
},
|
||||
{
|
||||
name: 'AuthorizationPrompt',
|
||||
declaration: 'export type AuthorizationPrompt = {\n signal?: AbortSignal;\n} & ({\n kind: \'text\';\n message: string;\n placeholder?: string;\n} | {\n kind: \'secret\';\n message: string;\n placeholder?: string;\n} | {\n kind: \'select\';\n message: string;\n options: readonly AuthorizationPromptOption[];\n});',
|
||||
},
|
||||
{
|
||||
name: 'AuthorizationPromptOption',
|
||||
declaration: 'export interface AuthorizationPromptOption {\n id: string;\n label: string;\n description?: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'AuthorizationRequest',
|
||||
declaration: 'export interface AuthorizationRequest {\n key: CredentialKey;\n method?: string;\n interaction: AuthorizationInteraction;\n signal?: AbortSignal;\n}',
|
||||
},
|
||||
{
|
||||
name: 'AuthorizationSession',
|
||||
declaration: 'export interface AuthorizationSession {\n readonly method: string;\n readonly signal: AbortSignal;\n notify(notice: AuthorizationNotice): void;\n prompt(prompt: AuthorizationPrompt): Promise<string>;\n}',
|
||||
},
|
||||
{
|
||||
name: 'AuthorizationSettlement',
|
||||
declaration: 'export type AuthorizationSettlement = AuthorizationStatus | \'failed\';',
|
||||
},
|
||||
{
|
||||
name: 'AuthorizationStatus',
|
||||
declaration: 'export type AuthorizationStatus = \'authorized\' | \'cancelled\';',
|
||||
},
|
||||
{
|
||||
name: 'BackendRegistry',
|
||||
declaration: 'export class BackendRegistry {\n register(name: string, backend: StorageBackend): () => void;\n get(name: string): StorageBackend;\n names(): string[];\n}',
|
||||
|
||||
Generated
+15
@@ -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':
|
||||
|
||||
@@ -64,6 +64,7 @@ export const SERVICE_PAGE: Record<string, string> = {
|
||||
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<string, string> = {
|
||||
'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<Record<string, string>> = {
|
||||
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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -133,6 +133,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'packages/settings/settings-file': { kind: 'indirect', reason: 'The file provider stores and publishes namespace sections; consumers of ctx.settings own any model-facing behavior.' },
|
||||
'packages/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.' },
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user