mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
fix(credentials): name reference update event explicitly
This commit is contained in:
+2
-2
@@ -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 .agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md
|
||||
2026-07-30-credential-boundaries-and-atomic-registration.md: 69ddfdcf3cd6c15218c672d43cc5aadfd48a0223
|
||||
2026-07-30-credential-boundaries-and-atomic-registration.zh.md: d234a073467c743aa90e8e60f1f96f4d288fd651
|
||||
2026-07-30-credential-boundaries-and-atomic-registration.md: 95c16ef2bf3d82d7b8b53e3975ece3f0c63402f3
|
||||
2026-07-30-credential-boundaries-and-atomic-registration.zh.md: 56ce0d8cbfc0f9023aca406ad43797e7347fdc77
|
||||
|
||||
+2
-2
@@ -8,7 +8,7 @@ English | [中文](2026-07-30-credential-boundaries-and-atomic-registration.zh.m
|
||||
|
||||
## Problem
|
||||
|
||||
The credential path leaked across boundaries it had drawn. The shipped surfaces hoisted `$DSH_HOME/.env` into `process.env` before cordis booted, so on the next run `credentials-local` classified every key it had stored itself as a read-only ambient launch override: `describe()` reported `source: 'env'` with `writable: false`, `set`/`unset` rejected as shadowed, and a key stored from the web page or TUI became unrotatable and undeletable while the adapter kept using the value captured at launch. The store's own write path repeated the settings-file defects the settings write-path note fixes (two independent chains, whole-file render from a stale cache), plus editor bugs of its own: a physical line inside another key's quoted multi-line value read as an assignment, CRLF endings degraded to LF, a multi-line entry reported `writable: true` while `set` always threw, and `credentials/updated` was emitted bare after the commit, so one broken observer made a durable write look failed. On the read side, the file's `0600` mode stops other OS users but not the model, whose bash and filesystem tools run as the same user.
|
||||
The credential path leaked across boundaries it had drawn. The shipped surfaces hoisted `$DSH_HOME/.env` into `process.env` before cordis booted, so on the next run `credentials-local` classified every key it had stored itself as a read-only ambient launch override: `describe()` reported `source: 'env'` with `writable: false`, `set`/`unset` rejected as shadowed, and a key stored from the web page or TUI became unrotatable and undeletable while the adapter kept using the value captured at launch. The store's own write path repeated the settings-file defects the settings write-path note fixes (two independent chains, whole-file render from a stale cache), plus editor bugs of its own: a physical line inside another key's quoted multi-line value read as an assignment, CRLF endings degraded to LF, a multi-line entry reported `writable: true` while `set` always threw, and `credentials/reference-updated` was emitted bare after the commit, so one broken observer made a durable write look failed. On the read side, the file's `0600` mode stops other OS users but not the model, whose bash and filesystem tools run as the same user.
|
||||
|
||||
Two request-path defects sat beside them. DeepSeek resolved connection and credential facts independently, so a settings generation the resolver rejected could still pair its credential choice with the previous generation's endpoint. pi-ai handed the SDK `undefined` when a configured `apiKeyEnv` resolved to nothing, letting pi-ai's own environment discovery authenticate with an unrelated provider key — another tenant, silently billed. And its route swap disposed the old registration before creating the new one: a route another adapter owned dropped every existing route, after which the facts cache could equal the registry's, so restoring the working configuration never re-applied.
|
||||
|
||||
@@ -22,7 +22,7 @@ Two request-path defects sat beside them. DeepSeek resolved connection and crede
|
||||
|
||||
**Route replacement is a registry operation, not a caller sequence.** `registerAdapter` returns a handle carrying `replace(providers)`: the candidate set is validated in full first (conflicts, names, provider metadata), then swapped in one synchronous section. A refused replacement leaves the previous routes registered and serving, and the caller's facts cache only advances after the registry actually holds the new set, so reverting to a working configuration re-applies. pi-ai's registration facts are sorted by provider, so a settings document that merely reorders its keys is no longer a route change.
|
||||
|
||||
**Contained publication for committed credential writes.** `CredentialProvider.notifyUpdated` fans `credentials/updated` out one listener at a time; sync throws and async rejections are logged without changing the committed operation's outcome, and `INVARIANT`-coded failures rethrow after every listener ran — the same shape the settings seam uses for `settings/updated`. `installSettingsSection`'s cleanup now distinguishes its two triggers: a provider detaching still falls back to the composition entry and re-derives, while the consumer's own unload returns immediately instead of re-registering routes during teardown.
|
||||
**Contained publication for committed credential writes.** `CredentialProvider.notifyUpdated` fans `credentials/reference-updated` out one listener at a time; sync throws and async rejections are logged without changing the committed operation's outcome, and `INVARIANT`-coded failures rethrow after every listener ran — the same shape the settings seam uses for `settings/updated`. `installSettingsSection`'s cleanup now distinguishes its two triggers: a provider detaching still falls back to the composition entry and re-derives, while the consumer's own unload returns immediately instead of re-registering routes during teardown.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@ Status: implemented
|
||||
|
||||
凭据路径越过它自己划下的边界发生了泄漏。已交付的各个面在 Cordis 启动之前就把 `$DSH_HOME/.env` 提升进了 `process.env`,于是下一次运行时,`credentials-local` 会把它自己存下的每个键都判成来自环境的只读启动覆盖:`describe()` 报告 `source: 'env'` 且 `writable: false`,`set`/`unset` 以被遮蔽为由拒绝,从 web 页面或 TUI 存入的密钥既无法轮换也无法删除,而适配器还在继续使用启动时捕获的那个值。
|
||||
|
||||
存储自身的写路径重演了 settings 写路径 note 在 settings-file 修掉的那些缺陷(两条相互独立的链、从陈旧缓存渲染整份文件),还叠加了编辑器自己的缺陷:另一个键的带引号多行值内部的一条物理行会被读成赋值,CRLF 行尾会退化成 LF,多行条目报告 `writable: true` 而 `set` 总是抛错,`credentials/updated` 又在提交之后裸发,于是一个出错的观察者就能让一次已经落盘的写入看起来失败。
|
||||
存储自身的写路径重演了 settings 写路径 note 在 settings-file 修掉的那些缺陷(两条相互独立的链、从陈旧缓存渲染整份文件),还叠加了编辑器自己的缺陷:另一个键的带引号多行值内部的一条物理行会被读成赋值,CRLF 行尾会退化成 LF,多行条目报告 `writable: true` 而 `set` 总是抛错,`credentials/reference-updated` 又在提交之后裸发,于是一个出错的观察者就能让一次已经落盘的写入看起来失败。
|
||||
|
||||
在读取一侧,文件的 `0600` 权限挡得住其他 OS 用户,却挡不住模型:它的 bash 与文件系统工具就以同一个用户身份运行。
|
||||
|
||||
@@ -26,7 +26,7 @@ Status: implemented
|
||||
|
||||
**路由替换是注册表的操作,不是调用方的一串步骤。**`registerAdapter` 返回一个携带 `replace(providers)` 的句柄:候选集合先被完整校验(冲突、名称、提供方元数据),再在一个同步区段内完成替换。被拒绝的替换会让先前的路由保持注册并继续服务,而调用方的事实缓存只有在注册表确实持有新集合之后才会推进,因此改回可用配置时会重新生效。pi-ai 的注册事实按提供方排序,因此仅仅调换键顺序的设置文档不再算作路由变更。
|
||||
|
||||
**已提交的凭据写入采用收容式发布。**`CredentialProvider.notifyUpdated` 逐个监听器扇出 `credentials/updated`;同步抛错与异步 rejection 都只记日志,不改变已提交操作的结果,而带 `INVARIANT` 代码的失败会在每个监听器都运行完之后重抛——与 settings seam 处理 `settings/updated` 的形状相同。`installSettingsSection` 的清理现在会区分它的两个触发来源:提供方脱离时仍回退到组合的 entry 配置并重新推导,而消费方自身卸载时立即返回,不再在拆卸过程中重新注册路由。
|
||||
**已提交的凭据写入采用收容式发布。**`CredentialProvider.notifyUpdated` 逐个监听器扇出 `credentials/reference-updated`;同步抛错与异步 rejection 都只记日志,不改变已提交操作的结果,而带 `INVARIANT` 代码的失败会在每个监听器都运行完之后重抛——与 settings seam 处理 `settings/updated` 的形状相同。`installSettingsSection` 的清理现在会区分它的两个触发来源:提供方脱离时仍回退到组合的 entry 配置并重新推导,而消费方自身卸载时立即返回,不再在拆卸过程中重新注册路由。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
|
||||
@@ -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 .agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md
|
||||
2026-08-10-remote-event-delivery.md: 9c2b5087772a5a343514d1766a14e90edb261813
|
||||
2026-08-10-remote-event-delivery.zh.md: 213715f5d9efcc11290059e5c5b0c06bbd7e255d
|
||||
2026-08-10-remote-event-delivery.md: ee3d9884b53f5fa5d0b0072660888c5f4d283b1b
|
||||
2026-08-10-remote-event-delivery.zh.md: a51a0ade174e70177f438a4bbc6bcd452807f436
|
||||
|
||||
@@ -8,7 +8,7 @@ English | [中文](2026-08-10-remote-event-delivery.zh.md)
|
||||
|
||||
[Typert Gateway targeted method calls](../../implemented/architecture/2026-08-02-typert-remote-method-calls.md) cover only the request/response shape and deliberately leave Session event streams and stateful interactions to separate designs. Every **one-way Host-to-consumer push** therefore still rides the legacy API Proxy.
|
||||
|
||||
The Host owns a family of one-way events whose payloads are already JSON and whose emission never binds an AgentScope: `agent-preset/selected`, `commands/change`, `credentials/updated`, `llm/adapters-updated`, and `settings/document-updated`. Reaching one UI subscriber took four hops: the Host cordis event, a hand-written `HostFrame` variant plus its zod branch in apiproxy, a hand-written bridge in client/runtime that re-emitted it as a Client cordis event, and finally the consumer's `ctx.on(...)`. Adding one such event edited five places (frame union, zod union, host-stream listener, client bridge, a duplicated Client-side `Events` declaration), and not one of them stated a new fact: the name, the payload type, and the emission point were all declared by the owner package's cordis `Events` merge.
|
||||
The Host owns a family of one-way events whose payloads are already JSON and whose emission never binds an AgentScope: `agent-preset/selected`, `commands/change`, `credentials/reference-updated`, `llm/adapters-updated`, and `settings/document-updated`. Reaching one UI subscriber took four hops: the Host cordis event, a hand-written `HostFrame` variant plus its zod branch in apiproxy, a hand-written bridge in client/runtime that re-emitted it as a Client cordis event, and finally the consumer's `ctx.on(...)`. Adding one such event edited five places (frame union, zod union, host-stream listener, client bridge, a duplicated Client-side `Events` declaration), and not one of them stated a new fact: the name, the payload type, and the emission point were all declared by the owner package's cordis `Events` merge.
|
||||
|
||||
That duplicated declaration is also **lossy**: the Client side restates it as `settings/changed(ns: string)`, flattening a branded type into bare `string` — the opposite of the Remote method contract, where a consumer type points at the business package's one canonical symbol.
|
||||
|
||||
@@ -75,7 +75,7 @@ Delivery shares no implementation with the cordis event system: one-way only, no
|
||||
export const API_REMOTE_FORWARDED_EVENTS = [
|
||||
'agent-preset/selected',
|
||||
'commands/change',
|
||||
'credentials/updated',
|
||||
'credentials/reference-updated',
|
||||
'llm/adapters-updated',
|
||||
'settings/document-updated',
|
||||
] as const
|
||||
|
||||
@@ -8,7 +8,7 @@ Status: implemented
|
||||
|
||||
[Typert Remote 方法调用](../../implemented/architecture/2026-08-02-typert-remote-method-calls.md)只覆盖「一次请求一个结果」的定向调用,明确把 Session 事件流与有状态交互留在别处;Host 向消费端的**单向事件推送**因此仍然全部压在遗留的 API Proxy 上。
|
||||
|
||||
Host 拥有 `agent-preset/selected`、`commands/change`、`credentials/updated`、`llm/adapters-updated`、`settings/document-updated` 这五条单向事件;它们既不依赖 AgentScope,载荷也本来就是 JSON。过去每条都要穿过 host cordis 事件、apiproxy 手写帧、client/runtime 手写桥和 Client 事件别名才能抵达 UI,而这些层没有陈述 owner 事件之外的新事实。
|
||||
Host 拥有 `agent-preset/selected`、`commands/change`、`credentials/reference-updated`、`llm/adapters-updated`、`settings/document-updated` 这五条单向事件;它们既不依赖 AgentScope,载荷也本来就是 JSON。过去每条都要穿过 host cordis 事件、apiproxy 手写帧、client/runtime 手写桥和 Client 事件别名才能抵达 UI,而这些层没有陈述 owner 事件之外的新事实。
|
||||
|
||||
那份重复声明还是**有损**的:client 侧写成 `settings/changed(ns: string)`,brand 类型在这一跳被拍平成裸 `string`,与 Remote 方法侧「消费端类型指向业务包唯一符号」的既有契约相反。
|
||||
|
||||
@@ -75,7 +75,7 @@ $dispatch(event: string, args: readonly unknown[]): void
|
||||
export const API_REMOTE_FORWARDED_EVENTS = [
|
||||
'agent-preset/selected',
|
||||
'commands/change',
|
||||
'credentials/updated',
|
||||
'credentials/reference-updated',
|
||||
'llm/adapters-updated',
|
||||
'settings/document-updated',
|
||||
] as const
|
||||
|
||||
@@ -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: 49c7a128fcf2539d342ad263bb856c40f2a33f0c
|
||||
event-producer-consumer.zh.md: dec8b443aa73ae919ceee7cd282846bea4e8ce18
|
||||
event-producer-consumer.md: e507e0e83fa32618393d06bfaa746a3e0b1e4611
|
||||
event-producer-consumer.zh.md: 94da41dbe23a870db959f5f878d025158efd2858
|
||||
|
||||
@@ -31,7 +31,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `cordis/request-run` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:367`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` |
|
||||
| `cordis/request-run-resolved` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:373`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` |
|
||||
| `credentials/record-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:87`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
|
||||
| `credentials/updated` | `emit` | [`packages/credentials/credentials/src/types.ts:75`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | `apiproxy`, [`credentials`](../packages/credentials/credentials) |
|
||||
| `credentials/reference-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:75`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | `apiproxy`, [`credentials`](../packages/credentials/credentials) |
|
||||
| `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) |
|
||||
| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:66`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) |
|
||||
| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:76`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem) |
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
| `cordis/request-run` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:367`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` |
|
||||
| `cordis/request-run-resolved` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:373`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` |
|
||||
| `credentials/record-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:87`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
|
||||
| `credentials/updated` | `emit` | [`packages/credentials/credentials/src/types.ts:75`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | `apiproxy`, [`credentials`](../packages/credentials/credentials) |
|
||||
| `credentials/reference-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:75`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | `apiproxy`, [`credentials`](../packages/credentials/credentials) |
|
||||
| `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) |
|
||||
| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:66`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) |
|
||||
| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:76`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem) |
|
||||
|
||||
@@ -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: 8d75a254109dcce5d0b54cd4fd293a8ca4e335c5
|
||||
credentials.zh.md: 46edda84a063c539748da344d7efa66c7af666e7
|
||||
credentials.md: 459511965d796cd34d1a817110016bcb922d2988
|
||||
credentials.zh.md: d833f7897008dd9f9c062d8fbefc1e4ba9c0d959
|
||||
|
||||
@@ -47,7 +47,7 @@ interface CredentialInfo {
|
||||
|
||||
## Change commits
|
||||
|
||||
`credentials/updated (ref)` fires after a committed change to a provider-managed source — a `set`, an `unset`, or an external edit observed in storage. Ambient process-environment changes are not observable and never emit. Consumers do not need the event (they re-resolve per operation); it exists for configuration surfaces refreshing a "configured" badge.
|
||||
`credentials/reference-updated (ref)` fires after a committed change to a provider-managed source — a `set`, an `unset`, or an external edit observed in storage. Ambient process-environment changes are not observable and never emit. Consumers do not need the event (they re-resolve per operation); it exists for configuration surfaces refreshing a "configured" badge.
|
||||
|
||||
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
|
||||
|
||||
@@ -244,16 +244,16 @@ Source: [`packages/credentials/authorization/src/index.ts:57`](../../packages/cr
|
||||
|
||||
#### `credentials/record-updated` — emit
|
||||
|
||||
Committed change to a stored credential record: a `modifyRecord` that wrote, a `deleteRecord` that removed, or an external edit observed in storage. Separate from `credentials/updated` because the two key grammars are disjoint — a listener that received both on one event could not tell which space a subject belongs to. Listener failures are contained on the same terms as `credentials/updated`.
|
||||
Committed change to a stored credential record: a `modifyRecord` that wrote, a `deleteRecord` that removed, or an external edit observed in storage. Separate from `credentials/reference-updated` because the two key grammars are disjoint — a listener that received both on one event could not tell which space a subject belongs to. Listener failures are contained on the same terms as `credentials/reference-updated`.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* Committed change to a stored credential record: a `modifyRecord` that
|
||||
* wrote, a `deleteRecord` that removed, or an external edit observed in
|
||||
* storage. Separate from `credentials/updated` because the two key
|
||||
* storage. Separate from `credentials/reference-updated` because the two key
|
||||
* grammars are disjoint — a listener that received both on one event could
|
||||
* not tell which space a subject belongs to. Listener failures are
|
||||
* contained on the same terms as `credentials/updated`.
|
||||
* contained on the same terms as `credentials/reference-updated`.
|
||||
* @param key - the record whose stored value changed.
|
||||
* @mode emit
|
||||
*/
|
||||
@@ -262,9 +262,9 @@ Committed change to a stored credential record: a `modifyRecord` that wrote, a `
|
||||
|
||||
Source: [`packages/credentials/credentials/src/types.ts:87`](../../packages/credentials/credentials/src/types.ts)
|
||||
|
||||
<a id="credentialsupdated--emit"></a>
|
||||
<a id="credentialsreference-updated--emit"></a>
|
||||
|
||||
#### `credentials/updated` — emit
|
||||
#### `credentials/reference-updated` — emit
|
||||
|
||||
Committed change to a provider-managed credential source: a `set`, an `unset`, or an external edit observed in storage. Ambient process-environment changes are not observable and never emit. Listener failures are contained and logged — a sync throw and an async rejection alike — without changing the committed operation's outcome, except `INVARIANT`-coded failures, which rethrow after every listener ran; that rethrow reaches the emitter only from synchronous listeners, so invariant checks on this event must not be async functions.
|
||||
|
||||
@@ -281,7 +281,7 @@ Committed change to a provider-managed credential source: a `set`, an `unset`, o
|
||||
* @param ref - the reference whose stored value changed.
|
||||
* @mode emit
|
||||
*/
|
||||
'credentials/updated'(ref: CredentialRef): void
|
||||
'credentials/reference-updated'(ref: CredentialRef): void
|
||||
```
|
||||
|
||||
Source: [`packages/credentials/credentials/src/types.ts:75`](../../packages/credentials/credentials/src/types.ts)
|
||||
|
||||
@@ -47,7 +47,7 @@ interface CredentialInfo {
|
||||
|
||||
## 已提交的变更
|
||||
|
||||
`credentials/updated (ref)` 在提供方管理的来源发生已提交变更后发出——`set`、`unset` 或在存储中观察到的外部编辑。进程环境自身的变化不可观测,永不发出事件。消费方不需要该事件(它们按操作重新解析);它服务于配置界面刷新「已配置」徽标。
|
||||
`credentials/reference-updated (ref)` 在提供方管理的来源发生已提交变更后发出——`set`、`unset` 或在存储中观察到的外部编辑。进程环境自身的变化不可观测,永不发出事件。消费方不需要该事件(它们按操作重新解析);它服务于配置界面刷新「已配置」徽标。
|
||||
|
||||
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
|
||||
|
||||
@@ -244,16 +244,16 @@ Source: [`packages/credentials/authorization/src/index.ts:57`](../../packages/cr
|
||||
|
||||
#### `credentials/record-updated` — emit
|
||||
|
||||
Committed change to a stored credential record: a `modifyRecord` that wrote, a `deleteRecord` that removed, or an external edit observed in storage. Separate from `credentials/updated` because the two key grammars are disjoint — a listener that received both on one event could not tell which space a subject belongs to. Listener failures are contained on the same terms as `credentials/updated`.
|
||||
Committed change to a stored credential record: a `modifyRecord` that wrote, a `deleteRecord` that removed, or an external edit observed in storage. Separate from `credentials/reference-updated` because the two key grammars are disjoint — a listener that received both on one event could not tell which space a subject belongs to. Listener failures are contained on the same terms as `credentials/reference-updated`.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* Committed change to a stored credential record: a `modifyRecord` that
|
||||
* wrote, a `deleteRecord` that removed, or an external edit observed in
|
||||
* storage. Separate from `credentials/updated` because the two key
|
||||
* storage. Separate from `credentials/reference-updated` because the two key
|
||||
* grammars are disjoint — a listener that received both on one event could
|
||||
* not tell which space a subject belongs to. Listener failures are
|
||||
* contained on the same terms as `credentials/updated`.
|
||||
* contained on the same terms as `credentials/reference-updated`.
|
||||
* @param key - the record whose stored value changed.
|
||||
* @mode emit
|
||||
*/
|
||||
@@ -262,9 +262,9 @@ Committed change to a stored credential record: a `modifyRecord` that wrote, a `
|
||||
|
||||
Source: [`packages/credentials/credentials/src/types.ts:87`](../../packages/credentials/credentials/src/types.ts)
|
||||
|
||||
<a id="credentialsupdated--emit"></a>
|
||||
<a id="credentialsreference-updated--emit"></a>
|
||||
|
||||
#### `credentials/updated` — emit
|
||||
#### `credentials/reference-updated` — emit
|
||||
|
||||
Committed change to a provider-managed credential source: a `set`, an `unset`, or an external edit observed in storage. Ambient process-environment changes are not observable and never emit. Listener failures are contained and logged — a sync throw and an async rejection alike — without changing the committed operation's outcome, except `INVARIANT`-coded failures, which rethrow after every listener ran; that rethrow reaches the emitter only from synchronous listeners, so invariant checks on this event must not be async functions.
|
||||
|
||||
@@ -281,7 +281,7 @@ Committed change to a provider-managed credential source: a `set`, an `unset`, o
|
||||
* @param ref - the reference whose stored value changed.
|
||||
* @mode emit
|
||||
*/
|
||||
'credentials/updated'(ref: CredentialRef): void
|
||||
'credentials/reference-updated'(ref: CredentialRef): void
|
||||
```
|
||||
|
||||
Source: [`packages/credentials/credentials/src/types.ts:75`](../../packages/credentials/credentials/src/types.ts)
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
export const API_REMOTE_FORWARDED_EVENTS = [
|
||||
'agent-preset/selected',
|
||||
'commands/change',
|
||||
'credentials/updated',
|
||||
'credentials/reference-updated',
|
||||
'cordis/request-run',
|
||||
'cordis/request-run-resolved',
|
||||
'cordis/dynamic-package',
|
||||
|
||||
@@ -28,7 +28,7 @@ function forwardedEventContracts(ctx: Context): void {
|
||||
const bare: typeof namespace = 'plain-string'
|
||||
void bare; void namespace; void source
|
||||
})
|
||||
ctx.remote.$on('credentials/updated', () => {})
|
||||
ctx.remote.$on('credentials/reference-updated', () => {})
|
||||
ctx.remote.$on('commands/change', () => {})
|
||||
ctx.remote.$on('llm/adapters-updated', () => {})
|
||||
ctx.remote.$on('agent-preset/selected', (sessionId, agentPreset) => {
|
||||
@@ -106,7 +106,7 @@ describe('wire event bridge', () => {
|
||||
})
|
||||
bench.sinks?.onHostEnvelope?.({
|
||||
rpcId: 'r4' as never,
|
||||
payload: { type: 'host/remote-event', event: 'credentials/updated', args: ['OPENAI_API_KEY'] },
|
||||
payload: { type: 'host/remote-event', event: 'credentials/reference-updated', args: ['OPENAI_API_KEY'] },
|
||||
})
|
||||
// The carrier does not second-guess the name: selecting what a consumer can
|
||||
// receive is the allowlist's job, and dropping an unsubscribed name is the
|
||||
@@ -118,7 +118,7 @@ describe('wire event bridge', () => {
|
||||
|
||||
expect(seen).toEqual([
|
||||
['settings/document-updated', 'llm-pi-ai', 7],
|
||||
['credentials/updated', 'OPENAI_API_KEY'],
|
||||
['credentials/reference-updated', 'OPENAI_API_KEY'],
|
||||
['nobody/listening', 'ignored'],
|
||||
])
|
||||
})
|
||||
|
||||
@@ -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/client/ui-settings-models/README.md
|
||||
README.md: 0d76d5f31585721ad8f5cd76dd223d72353fdd0c
|
||||
README.zh.md: 8ac336245c9d82420c10132b3b935cf00f5ea889
|
||||
README.md: 781d7a8134c8118ddf4a78f3ee153a8043bae0a6
|
||||
README.zh.md: a652e9a250d855c0e91f842ec2332f4a49f17856
|
||||
|
||||
@@ -10,7 +10,7 @@ The notice step owns its exact copy and version in `src/onboarding-copy.ts`. On
|
||||
|
||||
After that notice completes, the DeepSeek step projects first-run readiness from the same joined Models snapshot. ANY provider the user can already reach ends it without rendering — a registered route whose named credential reference is stored, including a read-only launch-environment credential, or one whose profile names no reference and therefore authenticates natively. Only a user with none is asked for the official DeepSeek key. A mounted, active adapter with a missing writable reference renders the existing `ProviderEditor` in credential-only mode inside the shared onboarding modal; `credentials.set` stays the only secret write, and no provider settings are changed. Configure later completes only this coordinator pass. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering; Models remains the diagnostic surface.
|
||||
|
||||
Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it mutates the fields it can see rather than rebuilding a section. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, with the same fields the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. A typed API key is judged on its own field the same way: after trimming, it must be non-empty and every character must be printable ASCII (`[\x21-\x7E]`), which is exactly what an HTTP header value can carry — the twin of `normalizeApiKey` in `@deepseek-ai/dsh-llm`, mirrored here because the source-plane split forbids importing it. A value matching a pasted `NAME=value` environment line or wrapped in matching quotes is refused as the same format failure; that pasted-line check runs only in the browser, since a false positive in a resolver would leave the environment refusing the key as well. A field holding only whitespace fails rather than being silently dropped, while an empty field is not a failure at all: it means keep the stored key on an editor card, and authenticate some other way on a create card. A refused key blocks both the write and the endpoint interrogation, so the page never spends a round trip to be told what the field already says. Each settings write carries the card's current `revision`, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict`; after settings commit, the card adopts the returned redacted user subtree and revision before storing the credential, which makes a failed credential stage retry only that stage. Deletion removes a configured, writable credential only when the profile names the page's derived `<ROUTE>_API_KEY` target, then unsets the profile; both operations are idempotent, and a partial failure remains in the identified confirmation dialog for retry. Environment credentials, custom references, and credentials whose target cannot be identified remain untouched. Once loaded, the page subscribes directly to forwarded `settings/document-updated`, `credentials/updated`, and `llm/adapters-updated` owner events, plus local `connection/reset`, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling.
|
||||
Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it mutates the fields it can see rather than rebuilding a section. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, with the same fields the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. A typed API key is judged on its own field the same way: after trimming, it must be non-empty and every character must be printable ASCII (`[\x21-\x7E]`), which is exactly what an HTTP header value can carry — the twin of `normalizeApiKey` in `@deepseek-ai/dsh-llm`, mirrored here because the source-plane split forbids importing it. A value matching a pasted `NAME=value` environment line or wrapped in matching quotes is refused as the same format failure; that pasted-line check runs only in the browser, since a false positive in a resolver would leave the environment refusing the key as well. A field holding only whitespace fails rather than being silently dropped, while an empty field is not a failure at all: it means keep the stored key on an editor card, and authenticate some other way on a create card. A refused key blocks both the write and the endpoint interrogation, so the page never spends a round trip to be told what the field already says. Each settings write carries the card's current `revision`, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict`; after settings commit, the card adopts the returned redacted user subtree and revision before storing the credential, which makes a failed credential stage retry only that stage. Deletion removes a configured, writable credential only when the profile names the page's derived `<ROUTE>_API_KEY` target, then unsets the profile; both operations are idempotent, and a partial failure remains in the identified confirmation dialog for retry. Environment credentials, custom references, and credentials whose target cannot be identified remain untouched. Once loaded, the page subscribes directly to forwarded `settings/document-updated`, `credentials/reference-updated`, and `llm/adapters-updated` owner events, plus local `connection/reset`, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling.
|
||||
|
||||
## Model list and endpoint interrogation
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
声明完成后,DeepSeek 步骤会从同一个 Models 联接快照得出首次运行就绪状态。只要用户已经能触达**任何**一个提供方,它就直接完成而不渲染——已注册且其具名凭据引用已存储的路由(包括来自启动环境且只读的凭据),或 profile 根本不指名引用、因而走原生认证的路由。只有二者皆无的用户才会被要求填写 DeepSeek 官方密钥。适配器已挂载且活跃、引用可写但尚未配置时,既有 `ProviderEditor` 会以仅凭据模式渲染在共用引导弹窗中;`credentials.set` 仍是唯一的 secret 写入,且不会改变提供方设置。「稍后配置」只完成协调器当前这一轮。适配器缺失、路由不活跃、联接失败、部署只读或设置/凭据能力不可用时,该步骤不渲染并直接完成;Models 页仍是诊断界面。
|
||||
|
||||
每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它只修改自己看得见的字段,而不重建分节。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,使用与 pi-ai 提供方表单相同的字段。两项容量都按数值键入,可带十进制的 `K` 或 `M` 后缀(`256K`、`1M`;`1M` 即 1000K),存储为纯数值,回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称,以及无法读取、非正数或非整数的容量都会在写入前失败。键入的 API 密钥同样在它自己的字段上被判定:trim 之后必须非空,且每个字符都是可打印 ASCII(`[\x21-\x7E]`)——这正是 HTTP 标头值所能承载的范围,是 `@deepseek-ai/dsh-llm` 中 `normalizeApiKey` 的孪生体,因源码平面分割禁止直接引入而在此镜像。与整行粘贴的 `NAME=value` 环境变量匹配或首尾成对引号包裹的值,会以同一条格式失败被拒绝;这项粘贴行检查只在浏览器中运行,因为 resolver 中的一次误判会连带让环境变量这条路也拒绝该密钥。只含空白的输入框会失败而不是被静默丢弃;留空则完全不是失败:在编辑卡片上意味着保持已存储的密钥,在新建卡片上则意味着以其他方式鉴权。被拒绝的密钥会同时拦截写入与端点探测,因此页面不会白花一次往返去换取字段上已经写明的答案。每次 settings 写入都携带卡片当前的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝;settings 提交成功后,卡片会在存储凭据前采用响应返回的脱敏用户子树与 revision,因此凭据阶段失败时,重试只会重复该阶段。删除操作只会在 profile 指向页面派生的 `<ROUTE>_API_KEY` 目标时清除已配置且可写的凭据,随后取消设置 profile;两项操作都具备幂等性,部分失败会停留在点名目标的确认对话框中供重试。环境凭据、自定义引用和无法识别目标的凭据保持不变。页面加载完成后会直接订阅转发的 owner 事件 `settings/document-updated`、`credentials/updated`、`llm/adapters-updated`,以及本地 `connection/reset`,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。
|
||||
每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它只修改自己看得见的字段,而不重建分节。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,使用与 pi-ai 提供方表单相同的字段。两项容量都按数值键入,可带十进制的 `K` 或 `M` 后缀(`256K`、`1M`;`1M` 即 1000K),存储为纯数值,回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称,以及无法读取、非正数或非整数的容量都会在写入前失败。键入的 API 密钥同样在它自己的字段上被判定:trim 之后必须非空,且每个字符都是可打印 ASCII(`[\x21-\x7E]`)——这正是 HTTP 标头值所能承载的范围,是 `@deepseek-ai/dsh-llm` 中 `normalizeApiKey` 的孪生体,因源码平面分割禁止直接引入而在此镜像。与整行粘贴的 `NAME=value` 环境变量匹配或首尾成对引号包裹的值,会以同一条格式失败被拒绝;这项粘贴行检查只在浏览器中运行,因为 resolver 中的一次误判会连带让环境变量这条路也拒绝该密钥。只含空白的输入框会失败而不是被静默丢弃;留空则完全不是失败:在编辑卡片上意味着保持已存储的密钥,在新建卡片上则意味着以其他方式鉴权。被拒绝的密钥会同时拦截写入与端点探测,因此页面不会白花一次往返去换取字段上已经写明的答案。每次 settings 写入都携带卡片当前的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝;settings 提交成功后,卡片会在存储凭据前采用响应返回的脱敏用户子树与 revision,因此凭据阶段失败时,重试只会重复该阶段。删除操作只会在 profile 指向页面派生的 `<ROUTE>_API_KEY` 目标时清除已配置且可写的凭据,随后取消设置 profile;两项操作都具备幂等性,部分失败会停留在点名目标的确认对话框中供重试。环境凭据、自定义引用和无法识别目标的凭据保持不变。页面加载完成后会直接订阅转发的 owner 事件 `settings/document-updated`、`credentials/reference-updated`、`llm/adapters-updated`,以及本地 `connection/reset`,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。
|
||||
|
||||
## 模型列表与端点询问
|
||||
|
||||
|
||||
@@ -108,7 +108,7 @@ export function apply(ctx: ClientContext): void {
|
||||
const refreshModels = (): void => { refreshIfLoaded(controller) }
|
||||
const disposers = [
|
||||
ctx.remote.$on('settings/document-updated', () => { refreshModels() }),
|
||||
ctx.remote.$on('credentials/updated', refreshModels),
|
||||
ctx.remote.$on('credentials/reference-updated', refreshModels),
|
||||
ctx.remote.$on('llm/adapters-updated', refreshModels),
|
||||
ctx.on('connection/reset', refreshModels),
|
||||
]
|
||||
|
||||
@@ -177,7 +177,7 @@ describe('pushed invalidations', () => {
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
// The fake wire face has no methods: a fetch attempt would throw.
|
||||
b.ctx.remote.$dispatch('settings/document-updated', ['llm-pi-ai', 1])
|
||||
b.ctx.remote.$dispatch('credentials/updated', ['OPENAI_API_KEY'])
|
||||
b.ctx.remote.$dispatch('credentials/reference-updated', ['OPENAI_API_KEY'])
|
||||
b.ctx.remote.$dispatch('llm/adapters-updated', [])
|
||||
b.ctx.emit('connection/reset')
|
||||
})
|
||||
@@ -210,7 +210,7 @@ describe('pushed invalidations', () => {
|
||||
)()
|
||||
injected.controller.store.update((state) => { state.status = 'ready' })
|
||||
const load = vi.spyOn(injected.controller, 'load').mockResolvedValue()
|
||||
b.ctx.remote.$dispatch('credentials/updated', ['DEEPSEEK_API_KEY'])
|
||||
b.ctx.remote.$dispatch('credentials/reference-updated', ['DEEPSEEK_API_KEY'])
|
||||
expect(load).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
|
||||
@@ -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/client/ui-settings-plugins/README.md
|
||||
README.md: 92ba426b6bee3ae73e184dfcfd418e889bd47086
|
||||
README.zh.md: dc319ea9f128016b6df821575cb4d75740f04579
|
||||
README.md: 0ee0edbebadad8e8e895002cd9eb60a20f45809d
|
||||
README.zh.md: 75cc63ccf5280bba1b94bcf8930b9ee9432440e5
|
||||
|
||||
@@ -20,7 +20,7 @@ A card stages what the user types and writes it only when they save. Each contro
|
||||
|
||||
Saving writes each staged field through the client settings scope, which fences every write with the namespace revision it read, so a form that has drifted from the document is refused rather than overwriting a concurrent change. The Host is the only authority on whether a value was accepted — its validators own the constraints no schema can express — so the card reads the section back afterwards and reports a save that did not land, keeping those drafts for the user to correct.
|
||||
|
||||
A key can also be written from another surface — the Models page addresses the same reference — which changes no settings section, so the card re-reads on the forwarded `credentials/updated` event for the reference it watches.
|
||||
A key can also be written from another surface — the Models page addresses the same reference — which changes no settings section, so the card re-reads on the forwarded `credentials/reference-updated` event for the reference it watches.
|
||||
|
||||
A field's presence in the raw user layer — not its value — is what marks it overridden; a reset clears that field so it re-inherits the composition layer. Secret-role fields never ride a response, so a key control starts blank, reports only whether one is configured, and writes through the credentials domain rather than the settings section; a blank draft writes nothing and keeps the stored key.
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
保存时,每个暂存字段都通过客户端 settings scope 写入,该 scope 用读取时的命名空间 revision 为每次写入设栅,因此已与文档脱节的表单会被拒绝,而不是覆盖并发变更。某个值是否被接受只有 Host 说了算——schema 表达不了的约束归它的校验器所有——因此卡片在写入后回读分节,报告没有落盘的保存,并保留这些草稿供用户修改。
|
||||
|
||||
密钥也可能从别的表层写入——模型页寻址的是同一个引用——而那不改变任何 settings 分节,因此卡片会在转发来的 `credentials/updated` 事件报告它所关注的引用时重读。
|
||||
密钥也可能从别的表层写入——模型页寻址的是同一个引用——而那不改变任何 settings 分节,因此卡片会在转发来的 `credentials/reference-updated` 事件报告它所关注的引用时重读。
|
||||
|
||||
字段是否被覆盖,取决于它是否出现在原始用户层中,而非取决于它的值;重置会清除该字段,使其重新继承组装层。secret 角色的字段绝不搭乘响应,因此密钥控件初始为空、只报告是否已配置,并经由 credentials 领域而非 settings 分节写入;空草稿不写入任何东西,保留已存密钥。
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ export function apply(ctx: ClientContext): void {
|
||||
// scope publishes nothing when one is written. This is the only signal that
|
||||
// a key written on another surface reached the Host.
|
||||
ctx.effect(
|
||||
() => ctx.remote.$on('credentials/updated', (ref) => { webSearch.refreshCredential(ref) }),
|
||||
() => ctx.remote.$on('credentials/reference-updated', (ref) => { webSearch.refreshCredential(ref) }),
|
||||
'ui-settings-plugins: credential invalidations',
|
||||
)
|
||||
|
||||
|
||||
@@ -180,7 +180,7 @@ describe('ui-settings-plugins apply', () => {
|
||||
|
||||
// A key written on another surface changes no settings section, so this
|
||||
// event is the only thing that reaches the card.
|
||||
ctx.remote.$dispatch('credentials/updated', ['DEEPSEEK_API_KEY'])
|
||||
ctx.remote.$dispatch('credentials/reference-updated', ['DEEPSEEK_API_KEY'])
|
||||
|
||||
await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalledTimes(1) })
|
||||
})
|
||||
@@ -192,7 +192,7 @@ describe('ui-settings-plugins apply', () => {
|
||||
await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalled() })
|
||||
describeCredentials.mockClear()
|
||||
|
||||
ctx.remote.$dispatch('credentials/updated', ['SOME_OTHER_KEY'])
|
||||
ctx.remote.$dispatch('credentials/reference-updated', ['SOME_OTHER_KEY'])
|
||||
await Promise.resolve()
|
||||
|
||||
expect(describeCredentials).not.toHaveBeenCalled()
|
||||
|
||||
@@ -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/credentials-local/README.md
|
||||
README.md: cc9c63bd689f3e5dd028add77fb72ca9eb541623
|
||||
README.zh.md: 2368d5ff7db3c515e3826d53efad2c35768328f0
|
||||
README.md: 1b8e4e5d89dc0eb5da2b04b6281c70316262c03b
|
||||
README.zh.md: 7ba7726e46a6cd5c6b26a2e55e001a750a0e6dd1
|
||||
|
||||
@@ -69,7 +69,7 @@ The provider creates the directory `0700` and creates or atomically replaces the
|
||||
|
||||
## Hot reload
|
||||
|
||||
External edits publish `credentials/updated` per changed reference after the snapshot is replaced **wholesale** — an entry deleted on disk never lingers in memory. Before Chokidar opens the target, the provider realpaths its deepest existing ancestor and restores any missing suffix; file access and diagnostics retain the configured path, while Windows cannot mix an 8.3 alias with long-form libuv events. The provider's own writes are recognized by content and publish exactly their one commit event. An unreadable or invalid document at runtime keeps the last good snapshot and warns; an absent file is an empty store; an unreadable or invalid file at boot fails loud.
|
||||
External edits publish `credentials/reference-updated` per changed reference after the snapshot is replaced **wholesale** — an entry deleted on disk never lingers in memory. Before Chokidar opens the target, the provider realpaths its deepest existing ancestor and restores any missing suffix; file access and diagnostics retain the configured path, while Windows cannot mix an 8.3 alias with long-form libuv events. The provider's own writes are recognized by content and publish exactly their one commit event. An unreadable or invalid document at runtime keeps the last good snapshot and warns; an absent file is an empty store; an unreadable or invalid file at boot fails loud.
|
||||
|
||||
## Security boundary
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ records:
|
||||
|
||||
## 热重载
|
||||
|
||||
外部编辑在快照**整体替换**后按变更引用逐个发布 `credentials/updated`——磁盘上删掉的条目绝不在内存滞留。在 Chokidar 打开目标之前,提供方会对层级最深的现有祖先路径执行 realpath 解析,再拼回缺失的后缀;文件访问和诊断仍使用配置路径,从而避免 Windows 混用 8.3 别名与 libuv 的长格式事件路径。提供方自己的写入按内容识别,只发布属于该次提交的一个事件。运行期文档不可读或无效时保留最后可用快照并告警;文件不存在即空存储;启动时不可读或无效则明确报错。
|
||||
外部编辑在快照**整体替换**后按变更引用逐个发布 `credentials/reference-updated`——磁盘上删掉的条目绝不在内存滞留。在 Chokidar 打开目标之前,提供方会对层级最深的现有祖先路径执行 realpath 解析,再拼回缺失的后缀;文件访问和诊断仍使用配置路径,从而避免 Windows 混用 8.3 别名与 libuv 的长格式事件路径。提供方自己的写入按内容识别,只发布属于该次提交的一个事件。运行期文档不可读或无效时保留最后可用快照并告警;文件不存在即空存储;启动时不可读或无效则明确报错。
|
||||
|
||||
<a id="security-boundary"></a>
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: the Service Definition companion (`dsh-credentials/invariant`) owns the
|
||||
* `credentials/updated` lifecycle contract; this provider's file/environment layering is
|
||||
* `credentials/reference-updated` lifecycle contract; this provider's file/environment layering is
|
||||
* asynchronous I/O pinned by its unit suite.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
@@ -41,7 +41,7 @@ async function boot(config: ConstructorParameters<typeof LocalCredentialProvider
|
||||
|
||||
function updates(ctx: Context): CredentialRef[] {
|
||||
const seen: CredentialRef[] = []
|
||||
ctx.on('credentials/updated', (ref) => {
|
||||
ctx.on('credentials/reference-updated', (ref) => {
|
||||
seen.push(ref)
|
||||
})
|
||||
return seen
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Third-review behaviors: read-modify-write under the writer lock (external
|
||||
// edits survive an API write), the contained credentials/updated fan-out (a
|
||||
// edits survive an API write), the contained credentials/reference-updated fan-out (a
|
||||
// broken observer never fails a committed write), and the YAML document
|
||||
// editor's isolation between entries.
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -45,7 +45,7 @@ describe('read-modify-write', () => {
|
||||
const path = join(dir, '.credentials.yaml')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
const seen: string[] = []
|
||||
ctx.on('credentials/updated', (ref) => { seen.push(ref) })
|
||||
ctx.on('credentials/reference-updated', (ref) => { seen.push(ref) })
|
||||
await ctx.credentials.set(ALPHA, 'one')
|
||||
// The external edit has landed on disk but no watcher reported it (watch
|
||||
// is off — the same blind spot as a debounce window or a missed event).
|
||||
@@ -117,11 +117,11 @@ describe('contained update fan-out', () => {
|
||||
it('does not fail a committed set when a listener throws, and later listeners still run', async () => {
|
||||
const dir = await tempDir()
|
||||
const ctx = await boot({ path: join(dir, '.credentials.yaml'), watch: false })
|
||||
ctx.on('credentials/updated', () => {
|
||||
ctx.on('credentials/reference-updated', () => {
|
||||
throw new Error('observer boom')
|
||||
})
|
||||
const second = vi.fn()
|
||||
ctx.on('credentials/updated', second)
|
||||
ctx.on('credentials/reference-updated', second)
|
||||
await expect(ctx.credentials.set(ALPHA, 'one')).resolves.toBeUndefined()
|
||||
expect(second).toHaveBeenCalledWith(ALPHA)
|
||||
expect(await ctx.credentials.resolve(ALPHA)).toEqual({ value: 'one', source: 'file' })
|
||||
@@ -133,7 +133,7 @@ describe('contained update fan-out', () => {
|
||||
// An unknown-returning function keeps the typed surface legal while the
|
||||
// runtime value is still the rejected promise the containment must handle.
|
||||
const boom = (): unknown => Promise.reject(new Error('async observer boom'))
|
||||
ctx.on('credentials/updated', boom)
|
||||
ctx.on('credentials/reference-updated', boom)
|
||||
await expect(ctx.credentials.set(ALPHA, 'one')).resolves.toBeUndefined()
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
})
|
||||
@@ -142,11 +142,11 @@ describe('contained update fan-out', () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.credentials.yaml')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
ctx.on('credentials/updated', () => {
|
||||
ctx.on('credentials/reference-updated', () => {
|
||||
throw Object.assign(new Error('forged relation'), { code: 'INVARIANT' })
|
||||
})
|
||||
const second = vi.fn()
|
||||
ctx.on('credentials/updated', second)
|
||||
ctx.on('credentials/reference-updated', second)
|
||||
await expect(ctx.credentials.set(ALPHA, 'one')).rejects.toThrow(/forged relation/)
|
||||
// Harness-fatal by design — but the write itself committed first.
|
||||
expect(second).toHaveBeenCalledWith(ALPHA)
|
||||
|
||||
@@ -147,7 +147,7 @@ describe('watcher pipeline', () => {
|
||||
const path = join(dir, '.credentials.yaml')
|
||||
const ctx = await boot({ path, debounceMs: 5 })
|
||||
let arm = true
|
||||
ctx.on('credentials/updated', () => {
|
||||
ctx.on('credentials/reference-updated', () => {
|
||||
if (!arm) return
|
||||
throw Object.assign(new Error('forged relation'), { code: 'INVARIANT' })
|
||||
})
|
||||
@@ -178,7 +178,7 @@ describe('watcher pipeline', () => {
|
||||
await fiber
|
||||
let disposed = false
|
||||
let postDisposeCommits = 0
|
||||
ctx.on('credentials/updated', () => {
|
||||
ctx.on('credentials/reference-updated', () => {
|
||||
if (disposed) postDisposeCommits += 1
|
||||
})
|
||||
|
||||
@@ -202,7 +202,7 @@ describe('watcher pipeline', () => {
|
||||
await writeCredentials(path, 'version: 1\nrefs:\n DSH_CRED_PIPE: doomed\n')
|
||||
const ctx = await boot({ path, debounceMs: 5 })
|
||||
const seen: string[] = []
|
||||
ctx.on('credentials/updated', (ref) => {
|
||||
ctx.on('credentials/reference-updated', (ref) => {
|
||||
seen.push(ref)
|
||||
})
|
||||
|
||||
@@ -221,7 +221,7 @@ describe('watcher pipeline', () => {
|
||||
await writeCredentials(path, 'version: 1\nrefs:\n DSH_CRED_PIPE: a\n')
|
||||
const ctx = await boot({ path, debounceMs: 5 })
|
||||
const seen: string[] = []
|
||||
ctx.on('credentials/updated', (ref) => {
|
||||
ctx.on('credentials/reference-updated', (ref) => {
|
||||
seen.push(ref)
|
||||
})
|
||||
|
||||
|
||||
@@ -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/credentials/README.md
|
||||
README.md: d1b48aa724801a482517679790bba172fa60d013
|
||||
README.zh.md: a936b97353d7a66b7f0f7f9a760f4542bb2a9b30
|
||||
README.md: ff2e9a7002a3a4a2527ec0a36a4472711da5be2a
|
||||
README.zh.md: 5a21361c8dec5f98a36e5a1753ad8fc2f670bc0b
|
||||
|
||||
@@ -46,7 +46,7 @@ await ctx.credentials.deleteRecord(key) // no-op when absent
|
||||
|
||||
A `grant` record's `payload` is opaque: the seam never reads, validates, or reshapes it. The one constraint is that it survives a JSON round trip, which the provider enforces on the way in and on the way out — a value the store could not read back exactly as written is refused rather than stored lossily.
|
||||
|
||||
`credentials/updated (ref)` fires after a committed change to a provider-managed source — a `set`, an `unset`, or an external edit observed in storage. Ambient process-environment changes are not observable and never emit. Consumers do not need the event (they re-resolve per operation); it exists for configuration UIs refreshing a "configured" badge. Its declaration lives in the client-safe `./types` subpath export together with the `CredentialRef` type it names (the package root re-exports the type), so a consumer outside the Host compilation face reads the very signature the Host emits instead of restating it.
|
||||
`credentials/reference-updated (ref)` fires after a committed change to a provider-managed source — a `set`, an `unset`, or an external edit observed in storage. Ambient process-environment changes are not observable and never emit. Consumers do not need the event (they re-resolve per operation); it exists for configuration UIs refreshing a "configured" badge. Its declaration lives in the client-safe `./types` subpath export together with the `CredentialRef` type it names (the package root re-exports the type), so a consumer outside the Host compilation face reads the very signature the Host emits instead of restating it.
|
||||
|
||||
The shadowing rule on `set`/`unset` is deliberate fail-loud: when a read-only source (the live process environment, in the local provider) currently supplies the reference, a write would appear to succeed while resolution keeps returning the shadowing value — the seam rejects instead, and `describe().writable` lets a UI render the reference read-only up front.
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ await ctx.credentials.deleteRecord(key) // no-op when absent
|
||||
|
||||
`grant` 记录的 `payload` 是不透明的:seam 从不读取、校验或重塑它。唯一约束是它能经受 JSON 往返,提供方在写入与读出两个方向都强制这一点——存储无法逐字读回的值会被拒绝,而不是有损地存下。
|
||||
|
||||
`credentials/updated (ref)` 在提供方管理的来源发生已提交变更后触发——`set`、`unset` 或在存储中观察到的外部编辑。进程环境变量的变化不可观测,永不触发。消费方不需要该事件(它们按操作重新解析);它服务于配置界面刷新「已配置」徽标。它的声明住在 client-safe 的 `./types` 子路径出口,与其点名的 `CredentialRef` 类型同处一处(包根继续 re-export 该类型),于是 Host 编译面之外的消费方读到的正是 Host 发射的那一份签名,而不必再写一遍。
|
||||
`credentials/reference-updated (ref)` 在提供方管理的来源发生已提交变更后触发——`set`、`unset` 或在存储中观察到的外部编辑。进程环境变量的变化不可观测,永不触发。消费方不需要该事件(它们按操作重新解析);它服务于配置界面刷新「已配置」徽标。它的声明住在 client-safe 的 `./types` 子路径出口,与其点名的 `CredentialRef` 类型同处一处(包根继续 re-export 该类型),于是 Host 编译面之外的消费方读到的正是 Host 发射的那一份签名,而不必再写一遍。
|
||||
|
||||
`set`/`unset` 的遮蔽规则有意采用明确报错的方式:当只读来源(本地提供方中即当前进程环境)正在提供该引用时,写入会表面成功而解析仍返回遮蔽值——seam 选择直接拒绝,并通过 `describe().writable` 让界面提前把该引用渲染为只读。
|
||||
|
||||
|
||||
@@ -263,7 +263,7 @@ export abstract class CredentialProvider extends Service {
|
||||
abstract deleteRecord(key: CredentialKey): Promise<void>
|
||||
|
||||
/**
|
||||
* Fan `credentials/updated` out with contained listener failures: every
|
||||
* Fan `credentials/reference-updated` out with contained listener failures: every
|
||||
* listener runs, and a sync throw or async rejection is logged without
|
||||
* changing the committed operation's outcome — except `INVARIANT`-coded
|
||||
* failures, which rethrow after every listener ran (the rethrow reaches the
|
||||
@@ -274,7 +274,7 @@ export abstract class CredentialProvider extends Service {
|
||||
* @param ref - the reference whose stored value changed.
|
||||
*/
|
||||
protected notifyUpdated(ref: CredentialRef): void {
|
||||
this.fanOut('credentials/updated', ref)
|
||||
this.fanOut('credentials/reference-updated', ref)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -290,7 +290,7 @@ export abstract class CredentialProvider extends Service {
|
||||
fan-out: the contained-dispatch shape is the reviewed listener-lifecycle
|
||||
contract, and extracting it would couple the two seams' event semantics. */
|
||||
/** The contained dispatch both notifications run through; see {@link notifyUpdated}. */
|
||||
private fanOut(event: 'credentials/updated' | 'credentials/record-updated', subject: string): void {
|
||||
private fanOut(event: 'credentials/reference-updated' | 'credentials/record-updated', subject: string): void {
|
||||
let invariantFailure: unknown
|
||||
const args = [event, subject]
|
||||
for (const listener of this.ctx.events.dispatch('emit', args) as Array<(...listenerArgs: unknown[]) => unknown>) {
|
||||
|
||||
@@ -14,7 +14,7 @@ export const name = 'credentials-invariant'
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* Install the commit-event lifecycle contract: `credentials/updated` names a
|
||||
* Install the commit-event lifecycle contract: `credentials/reference-updated` names a
|
||||
* committed provider-source change, so it can only fire while a credentials
|
||||
* service is live — an emission after disposal means a provider leaked work
|
||||
* past its teardown quiescence. The value relation itself (`describe`
|
||||
@@ -22,9 +22,9 @@ export const inject = ['invariants']
|
||||
* each provider's own suite.
|
||||
*/
|
||||
const install: InvariantInstaller = (ctx: Context, fail: InvariantFailure) => {
|
||||
ctx.on('credentials/updated', (ref) => {
|
||||
ctx.on('credentials/reference-updated', (ref) => {
|
||||
if (ctx.get('credentials') === undefined) {
|
||||
fail(`credentials/updated for "${ref}" emitted without a live credentials service`)
|
||||
fail(`credentials/reference-updated for "${ref}" emitted without a live credentials service`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -72,15 +72,15 @@ declare module '@deepseek-ai/cordis' {
|
||||
* @param ref - the reference whose stored value changed.
|
||||
* @mode emit
|
||||
*/
|
||||
'credentials/updated'(ref: CredentialRef): void
|
||||
'credentials/reference-updated'(ref: CredentialRef): void
|
||||
|
||||
/**
|
||||
* Committed change to a stored credential record: a `modifyRecord` that
|
||||
* wrote, a `deleteRecord` that removed, or an external edit observed in
|
||||
* storage. Separate from `credentials/updated` because the two key
|
||||
* storage. Separate from `credentials/reference-updated` because the two key
|
||||
* grammars are disjoint — a listener that received both on one event could
|
||||
* not tell which space a subject belongs to. Listener failures are
|
||||
* contained on the same terms as `credentials/updated`.
|
||||
* contained on the same terms as `credentials/reference-updated`.
|
||||
* @param key - the record whose stored value changed.
|
||||
* @mode emit
|
||||
*/
|
||||
|
||||
@@ -55,7 +55,7 @@ describe('the credentials seam through the memory provider', () => {
|
||||
it('stores through set, removes through unset, and emits the committed change', async () => {
|
||||
const ctx = await boot()
|
||||
const events: CredentialRef[] = []
|
||||
ctx.on('credentials/updated', ref => void events.push(ref))
|
||||
ctx.on('credentials/reference-updated', ref => void events.push(ref))
|
||||
|
||||
await ctx.credentials.set(REF, 'sk-live')
|
||||
expect(await ctx.credentials.resolve(REF)).toEqual({ value: 'sk-live', source: 'memory' })
|
||||
@@ -67,7 +67,7 @@ describe('the credentials seam through the memory provider', () => {
|
||||
it('rejects an empty set and keeps an absent unset silent', async () => {
|
||||
const ctx = await boot()
|
||||
const events: CredentialRef[] = []
|
||||
ctx.on('credentials/updated', ref => void events.push(ref))
|
||||
ctx.on('credentials/reference-updated', ref => void events.push(ref))
|
||||
|
||||
await expect(ctx.credentials.set(REF, '')).rejects.toThrow(/empty value/)
|
||||
await ctx.credentials.unset(REF)
|
||||
|
||||
@@ -22,7 +22,7 @@ describe('credentials invariant companion', () => {
|
||||
await ctx.plugin(InvariantRegistry)
|
||||
await ctx.plugin(CredentialsInvariant)
|
||||
|
||||
expect(() => { ctx.emit('credentials/updated', REF) }).toThrow(/invariant violated by "@deepseek-ai\/dsh-credentials"/)
|
||||
expect(() => { ctx.emit('credentials/reference-updated', REF) }).toThrow(/invariant violated by "@deepseek-ai\/dsh-credentials"/)
|
||||
})
|
||||
|
||||
it('reserves the package name against duplicate registration', async () => {
|
||||
|
||||
@@ -45,13 +45,13 @@ export class MemoryCredentials extends CredentialProvider {
|
||||
return Promise.reject(new Error('memory credentials: an empty value cannot be stored; use unset'))
|
||||
}
|
||||
this.store.set(ref, value)
|
||||
this.ctx.emit('credentials/updated', ref)
|
||||
this.ctx.emit('credentials/reference-updated', ref)
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
override unset(ref: CredentialRef): Promise<void> {
|
||||
if (this.store.delete(ref)) {
|
||||
this.ctx.emit('credentials/updated', ref)
|
||||
this.ctx.emit('credentials/reference-updated', ref)
|
||||
}
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
@@ -2553,13 +2553,13 @@ export const EVENT_API: readonly EventApiEntry[] = [
|
||||
mode: 'emit',
|
||||
signature: '\'credentials/record-updated\'(key: CredentialKey): void',
|
||||
summary: 'Committed change to a stored credential record: a `modifyRecord` that wrote, a `deleteRecord` that removed, or an external edit observed in storage.',
|
||||
description: 'Committed change to a stored credential record: a `modifyRecord` that wrote, a `deleteRecord` that removed, or an external edit observed in storage. Separate from `credentials/updated` because the two key grammars are disjoint — a listener that received both on one event could not tell which space a subject belongs to. Listener failures are contained on the same terms as `credentials/updated`.',
|
||||
description: 'Committed change to a stored credential record: a `modifyRecord` that wrote, a `deleteRecord` that removed, or an external edit observed in storage. Separate from `credentials/reference-updated` because the two key grammars are disjoint — a listener that received both on one event could not tell which space a subject belongs to. Listener failures are contained on the same terms as `credentials/reference-updated`.',
|
||||
parameters: [{ name: 'key', description: 'the record whose stored value changed.' }],
|
||||
},
|
||||
{
|
||||
name: 'credentials/updated',
|
||||
name: 'credentials/reference-updated',
|
||||
mode: 'emit',
|
||||
signature: '\'credentials/updated\'(ref: CredentialRef): void',
|
||||
signature: '\'credentials/reference-updated\'(ref: CredentialRef): void',
|
||||
summary: 'Committed change to a provider-managed credential source: a `set`, an `unset`, or an external edit observed in storage.',
|
||||
description: 'Committed change to a provider-managed credential source: a `set`, an `unset`, or an external edit observed in storage. Ambient process-environment changes are not observable and never emit. Listener failures are contained and logged — a sync throw and an async rejection alike — without changing the committed operation\'s outcome, except `INVARIANT`-coded failures, which rethrow after every listener ran; that rethrow reaches the emitter only from synchronous listeners, so invariant checks on this event must not be async functions.',
|
||||
parameters: [{ name: 'ref', description: 'the reference whose stored value changed.' }],
|
||||
|
||||
@@ -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/host/apiproxy/README.md
|
||||
README.md: a9fb07519c1d8b3b2e2d759bd9138dac60f2306f
|
||||
README.zh.md: 9bde3583b0f5e6ce172bfe145f440150aabfa230
|
||||
README.md: 4cf11c7ffe55484fd2c27e597931f22f8f861a14
|
||||
README.zh.md: df17e296eb5b02420825b736461ed9802adf7a17
|
||||
|
||||
@@ -58,7 +58,7 @@ The `agentPreset.list` domain exposes the deployment's preset roster so a browse
|
||||
|
||||
The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the composer's menu: it returns every user-invocable skill with its `modelInvocable` flag, so menus can mark user-only (`disable-model-invocation`) entries whose only invocation path is the slash gesture. Listing is the skill domain's only RPC — invocation itself is an ordinary `session.prompt` whose whitespace-bounded `/name` tokens `dsh-tool-skill` recognizes at the pre-step boundary and answers with injected `<skill_content>` context, so every entry point (Web, TUI, and ACP) shares one deterministic path—including for hand-typed text—with no dedicated invocation wire. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `commands/change` rides the forwarded-event frame as the registry-wide catalog invalidation signal: clients refetch `command.list` instead of diffing. Forwarded `agent-preset/selected` is its per-session counterpart, emitted from the logged selection commit: recomposing a blank session's agent re-parents its scope without registering anything, so both catalogs that session's composition decides (`command.list`, `skill.list`) go stale with no registry change to announce it.
|
||||
|
||||
The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves every registered namespace: a plugin distributed outside this repository becomes browser-configurable by registering its section, with no change here, and this proxy adds no boundary of its own — a name no registration answers folds into the seam's own `settings-rejected`. Which surface renders a namespace is the browser's decision (the plugin configuration page keys its cards on the namespace), never this proxy's. `settings.describe` returns each namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select any filesystem target. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. `llm.discoverModels` interrogates a provider endpoint the page is still drafting: `settingsNs` selects the adapter family that knows how to read the listing, and the endpoint, protocol, and key come from the form rather than from storage. It writes nothing — the reply is candidates, and only a later `settings.mutate` decides what a route serves — so its `apiKey` is the third payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`. The host never stores or returns it; like the other two it does ride the client's outgoing envelope, which `subscribeEnvelopes()` observers can see, and redacting that tap is a configuration-plane-wide change rather than this method's to make alone. Every refusal (an unserved namespace, a protocol with no readable listing, an unreachable endpoint, a rejected credential) folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. Invalidations keep every surface converged without polling. `settings/document-updated` and `credentials/updated` ride the verbatim forwarded-event frame (see below), so a raw settings change whose resolved value is unchanged still reaches clients, and a credential invalidation still carries reference names only, never values. `llm/adapters-updated` is forwarded beside `settings/document-updated`; concrete model consumers subscribe to both owner events directly because topology commits and settings documents can independently change their directories. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin.
|
||||
The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves every registered namespace: a plugin distributed outside this repository becomes browser-configurable by registering its section, with no change here, and this proxy adds no boundary of its own — a name no registration answers folds into the seam's own `settings-rejected`. Which surface renders a namespace is the browser's decision (the plugin configuration page keys its cards on the namespace), never this proxy's. `settings.describe` returns each namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select any filesystem target. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. `llm.discoverModels` interrogates a provider endpoint the page is still drafting: `settingsNs` selects the adapter family that knows how to read the listing, and the endpoint, protocol, and key come from the form rather than from storage. It writes nothing — the reply is candidates, and only a later `settings.mutate` decides what a route serves — so its `apiKey` is the third payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`. The host never stores or returns it; like the other two it does ride the client's outgoing envelope, which `subscribeEnvelopes()` observers can see, and redacting that tap is a configuration-plane-wide change rather than this method's to make alone. Every refusal (an unserved namespace, a protocol with no readable listing, an unreachable endpoint, a rejected credential) folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. Invalidations keep every surface converged without polling. `settings/document-updated` and `credentials/reference-updated` ride the verbatim forwarded-event frame (see below), so a raw settings change whose resolved value is unchanged still reaches clients, and a credential invalidation still carries reference names only, never values. `llm/adapters-updated` is forwarded beside `settings/document-updated`; concrete model consumers subscribe to both owner events directly because topology commits and settings documents can independently change their directories. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin.
|
||||
|
||||
## Carrier layer (`/client` + root)
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
|
||||
|
||||
`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和 skill(技能)目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于 composer 的菜单:它返回每一个用户可调用的 skill 及其 `modelInvocable` 标志,让菜单能够标出仅限用户(`disable-model-invocation`)的条目——斜杠手势是这类条目唯一的调用路径。列表是 skill 领域唯一的 RPC——调用本身就是一次普通的 `session.prompt`,`dsh-tool-skill` 会在 pre-step 边界识别其中以空白为界的 `/name` token,并以注入的 `<skill_content>` 上下文作答,因此所有入口(Web、TUI 与 ACP(Agent Client Protocol))共享同一条确定性路径,手动键入的文本也走该路径,且没有专设的调用协议。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`commands/change` 搭乘转发事件帧作为注册表级目录失效信号:客户端重新拉取 `command.list` 而不是做差分。转发的 `agent-preset/selected` 是它按会话粒度的对应物,由落账的选择提交点发出:重组空会话的 agent 只是重新挂接其 scope,不产生任何注册,因此该会话组成所决定的两份目录(`command.list`、`skill.list`)都会失效,却没有任何注册表变化来宣告它。
|
||||
|
||||
`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于每一个已注册 namespace:在本仓库之外分发的插件只要注册自己的分节即可变得可从浏览器配置,无需改动这里;本代理也不再自设边界——没有任何注册应答的名字会折叠为 seam 自己的 `settings-rejected`。由哪个界面渲染某个 namespace 是浏览器的决定(插件配置页按 namespace 为其卡片编键),从不由本代理决定。`settings.describe` 为每个 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储或回显。host 从不存储或回传它;与另两者一样,它确实会搭乘客户端的出站信封,`subscribeEnvelopes()` 的观察者能看到——为该 tap 做脱敏是整个配置面的改动,而非本方法一家的事。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据)都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。失效通知让每个面无需轮询即保持收敛。`settings/document-updated` 与 `credentials/updated` 搭乘原样转发事件帧(见下),因此解析值未变的原始设置变更同样能到达客户端,凭据失效通知也仍然只带引用名、绝不带值。`llm/adapters-updated` 与 `settings/document-updated` 一并原样转发;具体模型消费方直接订阅这两个 owner 事件,因为拓扑提交和设置文档都能独立改变其目录。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据提供方的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。
|
||||
`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于每一个已注册 namespace:在本仓库之外分发的插件只要注册自己的分节即可变得可从浏览器配置,无需改动这里;本代理也不再自设边界——没有任何注册应答的名字会折叠为 seam 自己的 `settings-rejected`。由哪个界面渲染某个 namespace 是浏览器的决定(插件配置页按 namespace 为其卡片编键),从不由本代理决定。`settings.describe` 为每个 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储或回显。host 从不存储或回传它;与另两者一样,它确实会搭乘客户端的出站信封,`subscribeEnvelopes()` 的观察者能看到——为该 tap 做脱敏是整个配置面的改动,而非本方法一家的事。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据)都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。失效通知让每个面无需轮询即保持收敛。`settings/document-updated` 与 `credentials/reference-updated` 搭乘原样转发事件帧(见下),因此解析值未变的原始设置变更同样能到达客户端,凭据失效通知也仍然只带引用名、绝不带值。`llm/adapters-updated` 与 `settings/document-updated` 一并原样转发;具体模型消费方直接订阅这两个 owner 事件,因为拓扑提交和设置文档都能独立改变其目录。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据提供方的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。
|
||||
|
||||
## 载体层(`/client` + 根路径)
|
||||
|
||||
|
||||
@@ -123,7 +123,7 @@ class MemoryCredentials extends CredentialProvider {
|
||||
return Promise.reject(new Error(`credentials: ${ref} is shadowed by the read-only environment`))
|
||||
}
|
||||
this.values.set(ref, value)
|
||||
this.ctx.emit('credentials/updated', ref)
|
||||
this.ctx.emit('credentials/reference-updated', ref)
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
@@ -132,7 +132,7 @@ class MemoryCredentials extends CredentialProvider {
|
||||
return Promise.reject(new Error(`credentials: ${ref} is shadowed by the read-only environment`))
|
||||
}
|
||||
this.values.delete(ref)
|
||||
this.ctx.emit('credentials/updated', ref)
|
||||
this.ctx.emit('credentials/reference-updated', ref)
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
@@ -632,8 +632,8 @@ describe('credentials domain', () => {
|
||||
expectOk(await api.credentials.unset(request({ ref: 'OPENAI_API_KEY' })))
|
||||
})
|
||||
expect(frames).toEqual([
|
||||
{ type: 'host/remote-event', event: 'credentials/updated', args: ['OPENAI_API_KEY'] },
|
||||
{ type: 'host/remote-event', event: 'credentials/updated', args: ['OPENAI_API_KEY'] },
|
||||
{ type: 'host/remote-event', event: 'credentials/reference-updated', args: ['OPENAI_API_KEY'] },
|
||||
{ type: 'host/remote-event', event: 'credentials/reference-updated', args: ['OPENAI_API_KEY'] },
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ describe('TestRemote', () => {
|
||||
new TestRemote(ctx)
|
||||
// No subscriber for this name: the emit must be inert rather than throwing,
|
||||
// because the wire carries whatever the Host allowlist selected.
|
||||
expect(() => { ctx.remote.$dispatch('credentials/updated', ['DEEPSEEK_API_KEY']) }).not.toThrow()
|
||||
expect(() => { ctx.remote.$dispatch('credentials/reference-updated', ['DEEPSEEK_API_KEY']) }).not.toThrow()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user