diff --git a/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.i18n.yaml new file mode 100644 index 0000000000..b5eb723680 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.md +2026-07-30-config-plane-boundaries.md: 8a29dcc934126d6e3dfa9c0a6a308ef006017a5a +2026-07-30-config-plane-boundaries.zh.md: c858b7dd5b6fcd61936c33f1f09d7d2e89a3cfc7 diff --git a/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.md b/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.md new file mode 100644 index 0000000000..8a29dcc934 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.md @@ -0,0 +1,41 @@ +# Agent Note: what the configuration plane exposes, and who may overwrite what + +Status: implemented + +English | [中文](2026-07-30-config-plane-boundaries.zh.md) + +> Scope: the review round over the [web configuration plane](2026-07-30-web-config-plane.md) — which namespaces reach the wire, which callers reach them, and how an editor holding a partial, possibly stale view writes without destroying what it cannot see. + +## Problem + +The plane worked and was reachable by more callers, and with more authority, than its design claimed. + +`trustedHosts` gated only writes, so a declared LAN client could call `settings.describe` — every exposed namespace's configuration — and `credentials.describe`, which reports whether an arbitrary environment-variable name is configured and where it resolves from. That fence is a DNS-rebinding defense and says so; treating it as an authorization boundary for reads was a category error. Separately, the proxy served every registered namespace: the settings seam is deliberately general, so the first plugin to call `settings.register()` for its own configuration would silently become remotely readable and writable, without passing anywhere near a review of the web surface. + +The editor was worse than reachable — it was destructive. It reads the redacted descriptor, which by construction omits `role('secret')` fields. Clearing one field rebuilt the whole user section from that redacted copy and sent `settings.replace`, so a stored literal `apiKey` the wire had never returned was deleted as a side effect. Reproduced directly: `{baseURL, reasoning}` in, `apiKey` gone. Row removal took the same path. And nothing carried a version, so two tabs editing one namespace silently overwrote each other; the seam's per-namespace write queue orders writes but cannot tell a fresh writer from one replaying a stale snapshot. + +Three smaller defects sat beside them. `llm/adapters-updated` documented contained observer failures but only caught synchronous ones, so an async listener's rejection escaped as an unhandled rejection. llm-deepseek's retry-policy swap disposed its registration before re-registering, publishing an empty route set between the two — an observer saw the provider disappear and come back, despite a comment claiming no such window. And a transport rejection during the page's credential enrichment escaped `load()`, stranding the page in `loading` with no error shown. + +## Decision + +**Reading configuration is as privileged as writing it.** `settings.describe` and `credentials.describe` join the loopback-only set, so the whole configuration plane stays same-origin until real authentication exists. The model catalog (`llm.providers`, `llm.models`) deliberately does not: it carries provider ids, display names, and model lists — no endpoints, no key state — and a LAN client's model picker needs it. The boundary is asserted over a real HTTP server rather than a hand-assembled request, because the `Host` header a browser actually sends is what decides it. + +**The plane serves exactly the namespaces a registered model provider addresses.** `ctx.llm.listConfigurableProviders()` is the allow-list, so the product boundary is enforced rather than inferred from today's plugin set, and a future namespace becomes web-configurable only by joining that directory. An unregistered namespace and an unexposed one answer identically (`settings-not-exposed`), so probing cannot enumerate the registry. + +**A caller with a partial view names the field it means.** `Settings.mutate(ns, ops)` applies `set`/`unset` path ops to the section as it stands at the front of the write queue. The client builds ops by diffing its opening snapshot against its draft, so it mentions only fields it can see: a secret absent from both sides produces no op and survives by construction, not by care. `replace` remains the deliberate wholesale reset. + +**Staleness is detected, not ordered away.** Each namespace carries a monotonic `revision` over its RAW section; writes may carry `expectedRevision`, and a mismatch rejects with `SettingsConflictError` → `settings-conflict` on the wire, both revisions attached. The editor captures the revision it opened at and, on conflict, tells the user to reopen rather than replaying its snapshot. + +**The raw layer gets its own event.** `settings/updated` stays gated on the resolved value — that is what a consumer means by change. `settings/document-updated (ns, revision)` fires on any raw-section change, because a configuration surface must learn that a field went from inherited to overridden (same resolved value, different meaning) and that its held revision is stale. The host frame `host/settings-changed` now rides this event, and a change to an exposed provider namespace also emits `host/models-changed`: that namespace holds the provider's catalog, which no route change announces. + +## Alternatives considered + +- **A deployment-declared namespace allowlist on the proxy config** — more general, but it moves the product boundary to whoever writes cordis.yml, and an empty default would break the shipped page until every deployment opted in. The provider directory already states exactly which namespaces are model configuration. +- **Opt-in metadata at `settings.register()`** — the most honest semantics (the namespace's owner declares its own exposure), and the largest change: the seam's public interface, both LLM plugins, and their docs. Recorded as the shape to adopt if a non-LLM namespace ever needs the plane. +- **Distinguishing "unregistered" from "registered but unexposed"** — better diagnostics, and a namespace-enumeration oracle. The uniform answer is deliberate. +- **Detecting conflicts by diffing instead of a revision** — comparing the submitted base against storage would work for whole-section writes, but the editor holds a REDACTED section: it cannot produce a comparable base, which is the same reason it cannot safely `replace`. A counter needs neither. +- **Fixing the redaction gaps in this round** — `redactSecrets` walks only `object`/`dict`/`array`, so a secret behind a union, intersection, or transform is returned verbatim with an empty `secrets` list; `schema.toJSON()` carries a secret field's `.default(...)`; write-rejection messages return schema text that may quote the input; the client rehydrates the envelope through schemastery's `new Function`; and pi-ai's plain-string `headers` dict can legitimately hold `Authorization`. All confirmed, all deliberately left for a fail-closed `describeForWire()` that refuses a schema it cannot prove safe. They are recorded as `TODO(settings-wire-redaction)` and in the owning READMEs' Known Limitations rather than half-fixed here. + +## Consequences + +A LAN client on a `trustedHosts` deployment can no longer render the settings page at all; loopback is the configuration surface. A plugin that registers a settings namespace is not web-configurable until it also registers a configurable provider — deliberate, and the reason `settings-not-exposed` names the boundary in its message. `SettingsDescriptor` gained a required `revision`, so any programmatic constructor of a descriptor-shaped value must supply it, and `settings/document-updated` is a new event any provider-side listener may now observe. Clients that ignore `expectedRevision` keep last-write-wins semantics unchanged. Deferred: the fail-closed wire describe (with the `headers` and envelope-sanitization work it carries), and a non-executable browser schema protocol. diff --git a/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.zh.md b/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.zh.md new file mode 100644 index 0000000000..c858b7dd5b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.zh.md @@ -0,0 +1,41 @@ +# Agent Note:配置面暴露什么,以及谁有权覆盖什么 + +Status: implemented + +[English](2026-07-30-config-plane-boundaries.md) | 中文 + +> 范围:针对 [Web 配置面](2026-07-30-web-config-plane.md)的评审轮——哪些 namespace 能抵达协议、哪些调用方能抵达它们,以及一个只持有局部、且可能过期视图的编辑器该如何写入,才不会毁掉它看不见的东西。 + +## 问题 + +这个面能用,但能触达它的调用方、以及它们所拥有的权限,都比设计声称的更多。 + +`trustedHosts` 只拦住了写入,因此一个已声明的 LAN 客户端可以调用 `settings.describe`——拿到每个已暴露 namespace 的配置——以及 `credentials.describe`,后者会报告任意一个环境变量名是否已配置、又从何处解析。那道 fence 是 DNS 重绑定防御,它自己也是这么写的;把它当作读取的授权边界,是一次范畴错误。另一件事是:代理服务于每一个已注册的 namespace。settings seam 是刻意做成通用的,因此第一个为自身配置调用 `settings.register()` 的插件,就会悄无声息地变成可远程读写,而完全不必经过任何针对 Web 表层的评审。 + +编辑器比"可触达"更糟——它是破坏性的。它读到的是脱敏后的 descriptor,后者按构造省略了 `role('secret')` 字段。清空其中一个字段,会用这份脱敏副本重建整个用户分节并发出 `settings.replace`,于是一个协议从未回传过的已存字面 `apiKey` 被顺带删除。这一点被直接复现:输入 `{baseURL, reasoning}`,输出时 `apiKey` 消失。删除整行走的是同一条路径。而且没有任何东西携带版本,因此两个标签页编辑同一个 namespace 会静默互相覆盖;seam 的逐 namespace 写队列只排定写入次序,分辨不出一个新写方与一个重放过期快照的写方。 + +另有三个较小的缺陷与之并列。`llm/adapters-updated` 的文档写着观察者失败会被收容,却只捕获同步失败,于是异步 listener 的 rejection 作为 unhandled rejection 逃逸。llm-deepseek 的重试策略换路由先释放注册、再重新注册,在两者之间发布了一个空路由集——观察者会看到该提供方消失又回来,尽管注释宣称不存在这样的空窗。还有,页面做凭据增强时的传输层 rejection 会逃出 `load()`,把页面卡在 `loading` 且不显示任何错误。 + +## 决策 + +**读配置与写配置同样特权。**`settings.describe` 与 `credentials.describe` 加入仅限回环的集合,因此在真正的认证层出现之前,整个配置面都保持同源。模型目录(`llm.providers`、`llm.models`)刻意不在其中:它携带的是提供方 id、显示名与模型列表——没有端点、没有密钥状态——而 LAN 客户端的模型选择器正需要它。这条边界由一台真实 HTTP 服务器来断言,而不是手工拼装的请求,因为真正决定它的,是浏览器实际发出的那个 `Host` 头。 + +**这个面恰好服务于已注册模型提供方所指向的那些 namespace。**`ctx.llm.listConfigurableProviders()` 就是允许列表,于是产品边界是被执行的,而不是从今天的插件集合里推断出来的;将来的 namespace 只有加入该目录才会变得可在 Web 上配置。未注册的 namespace 与未暴露的 namespace 得到完全相同的答复(`settings-not-exposed`),因此探测无法枚举注册表。 + +**持有局部视图的调用方,点名它真正要改的字段。**`Settings.mutate(ns, ops)` 会把 `set`/`unset` 路径 op 施加在写入排到队首那一刻的分节上。客户端通过对比自己打开时的快照与草稿来构造 op,因此它只提及自己看得见的字段:两侧都没有的机密不会产生任何 op,它的留存是构造使然,而非小心使然。`replace` 仍是那个刻意的整体重置。 + +**过期是被检测出来的,而不是靠排序绕过去的。**每个 namespace 都带有一个针对其**原始**分节的单调 `revision`;写入可携带 `expectedRevision`,不匹配即以 `SettingsConflictError` 拒绝——在协议上是 `settings-conflict`,并附上两个 revision。编辑器记住自己打开时的 revision,冲突时请用户重新打开,而不是把自己的快照重放上去。 + +**原始层拥有自己的事件。**`settings/updated` 仍以解析值为门槛——那才是消费方所说的"变化"。`settings/document-updated (ns, revision)` 则在任何原始分节变化时触发,因为配置界面必须知道某个字段从继承变成了覆盖(解析值相同,含义不同),也必须知道自己持有的 revision 已经过期。host 帧 `host/settings-changed` 现在搭乘这个事件;而已暴露提供方 namespace 的变更还会额外发出 `host/models-changed`:该 namespace 正持有这个提供方的目录,而没有任何路由变更会宣告它。 + +## 曾考虑的替代方案 + +- **在代理配置上做部署声明式的 namespace 白名单**——更通用,但它把产品边界交给了写 cordis.yml 的人,而空的默认值会让已交付的页面在每个部署显式开启之前直接失效。提供方目录本就精确地说明了哪些 namespace 属于模型配置。 +- **在 `settings.register()` 处 opt-in metadata**——语义最正(由 namespace 的属主自行声明其暴露与否),改动也最大:seam 的公共接口、两个 LLM 插件,以及它们的文档。记录为:一旦某个非 LLM 的 namespace 确实需要这个面,就采用这个形状。 +- **区分"未注册"与"已注册但未暴露"**——诊断更好,同时也是一台 namespace 枚举预言机。统一答复是刻意为之。 +- **用 diff 而非 revision 来检测冲突**——对整分节写入而言,拿提交时的基线与存储比对是可行的,但编辑器持有的是**脱敏后**的分节:它给不出可比对的基线,这与它不能安全地 `replace` 是同一个原因。计数器两者都不需要。 +- **本轮就修掉脱敏的缺口**——`redactSecrets` 只遍历 `object`/`dict`/`array`,因此藏在 union、intersection 或 transform 之后的机密会被原样返回,且 `secrets` 列表为空;`schema.toJSON()` 会带上 secret 字段的 `.default(...)`;写入拒绝的消息返回的是可能引用了输入的 schema 文本;客户端通过 schemastery 的 `new Function` 重建信封;而 pi-ai 那个纯字符串的 `headers` 字典完全可以合法地放下 `Authorization`。全部经确认属实,也全部刻意留给一个 fail-closed 的 `describeForWire()`——它会拒绝自己无法证明安全的 schema。它们被记录为 `TODO(settings-wire-redaction)` 以及各属主 README 的 Known Limitations,而不是在这里做一半。 + +## 影响 + +`trustedHosts` 部署下的 LAN 客户端已经完全无法渲染设置页;配置表层就是回环。注册了 settings namespace 的插件,在它同时注册可配置提供方之前不会变得可在 Web 上配置——这是刻意的,也正是 `settings-not-exposed` 要在消息里点明这条边界的原因。`SettingsDescriptor` 新增了必填的 `revision`,因此以编程方式构造 descriptor 形状值的地方都必须提供它;`settings/document-updated` 是一个新事件,provider 侧的任何 listener 现在都可以观察它。忽略 `expectedRevision` 的客户端,其后写胜出的语义完全不变。延后事项:fail-closed 的协议 describe(连同它所承载的 `headers` 与信封净化工作),以及一套客户端无法执行的浏览器 schema 协议。 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 7db095865b..b60e5c9144 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -706,6 +706,29 @@ Source: [`packages/core/session/src/index.ts:103`](../../packages/core/session/s ## `settings/*` +### `settings/document-updated` — emit + +One registered namespace's RAW user section changed, whether or not the resolved value did. `settings/updated` is the consumer-facing event and stays deep-equal-gated; this one exists for configuration surfaces, which must learn that a field went from inherited to overridden (same resolved value, different meaning) and that their held revision is stale. Listener containment matches `settings/updated`. + +```ts cordis-catalog +/** + * One registered namespace's RAW user section changed, whether or not the + * resolved value did. `settings/updated` is the consumer-facing event and + * stays deep-equal-gated; this one exists for configuration surfaces, + * which must learn that a field went from inherited to overridden (same + * resolved value, different meaning) and that their held revision is + * stale. Listener containment matches `settings/updated`. + * @param ns - the namespace whose stored section changed. + * @param revision - the namespace's new revision. + * @mode emit + */ +'settings/document-updated'(ns: SettingsNamespace, revision: number): void +``` + +Types: [SettingsNamespace](../core-data-structures/settings.md) + +Source: [`packages/settings/settings/src/index.ts:150`](../../packages/settings/settings/src/index.ts) + ### `settings/updated` — emit Committed change to one registered namespace's resolved value. Emitted after the provider persisted (for `update`) or published (`provider`) the change; never emitted when the resolved value is deep-equal. Listener failures are contained and logged — a sync throw and an async rejection alike — 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. @@ -731,7 +754,7 @@ Committed change to one registered namespace's resolved value. Emitted after the Types: [SettingsNamespace](../core-data-structures/settings.md) · [SettingsUpdateSource](../core-data-structures/settings.md) -Source: [`packages/settings/settings/src/index.ts:130`](../../packages/settings/settings/src/index.ts) +Source: [`packages/settings/settings/src/index.ts:137`](../../packages/settings/settings/src/index.ts) ## `skills/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 36c90c41d0..5e8b2d4fdc 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1742,8 +1742,10 @@ get(ns: SettingsNamespace): unknown * merging over the previous write's committed section. * @param ns - the registered namespace to update. * @param patch - plain-object patch over the user section. + * @param expectedRevision - the descriptor `revision` the caller read; a + * namespace that moved past it rejects with {@link SettingsConflictError}. */ -async update(ns: SettingsNamespace, patch: object): Promise +async update(ns: SettingsNamespace, patch: object, expectedRevision?: number): Promise /** * Replace one registered namespace's user section wholesale, validate, @@ -1752,13 +1754,29 @@ async update(ns: SettingsNamespace, patch: object): Promise * merge-only patch cannot express (`replace({})` re-inherits everything). * @param ns - the registered namespace to replace. * @param section - the complete next user section. + * @param expectedRevision - the descriptor `revision` the caller read; a + * namespace that moved past it rejects with {@link SettingsConflictError}. */ -async replace(ns: SettingsNamespace, section: object): Promise +async replace(ns: SettingsNamespace, section: object, expectedRevision?: number): Promise + +/** + * Apply path-addressed edits to one registered namespace's user section, + * validate, persist, then commit and emit. The ops are applied to the + * section as it stands when the write reaches the front of the queue, so a + * caller never has to restate fields it did not touch — and, crucially, + * cannot delete fields it never saw. This is the write path for any caller + * holding a redacted view; `replace` remains the wholesale reset. + * @param ns - the registered namespace to edit. + * @param ops - ordered path edits; later ops observe earlier ones. + * @param expectedRevision - the descriptor `revision` the caller read; a + * namespace that moved past it rejects with {@link SettingsConflictError}. + */ +async mutate(ns: SettingsNamespace, ops: readonly SettingsPathOp[], expectedRevision?: number): Promise ``` -Types: [SettingsDescribeOptions](../core-data-structures/settings.md) · [SettingsDescriptor](../core-data-structures/settings.md) · [SettingsNamespace](../core-data-structures/settings.md) · [SettingsRegisterOptions](../core-data-structures/settings.md) · [SettingsScope](../core-data-structures/settings.md) +Types: [SettingsDescribeOptions](../core-data-structures/settings.md) · [SettingsDescriptor](../core-data-structures/settings.md) · [SettingsNamespace](../core-data-structures/settings.md) · [SettingsPathOp](../core-data-structures/settings.md) · [SettingsRegisterOptions](../core-data-structures/settings.md) · [SettingsScope](../core-data-structures/settings.md) -Source: [`packages/settings/settings/src/index.ts:270`](../../packages/settings/settings/src/index.ts) +Source: [`packages/settings/settings/src/index.ts:365`](../../packages/settings/settings/src/index.ts) ## `ctx.skills` — `SkillService` diff --git a/docs/core-data-structures/settings.i18n.yaml b/docs/core-data-structures/settings.i18n.yaml index c422ab1dcc..50c20a0aab 100644 --- a/docs/core-data-structures/settings.i18n.yaml +++ b/docs/core-data-structures/settings.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/settings.md -settings.md: b1e66b55c252084bad776fbd2a023167caa9fab0 -settings.zh.md: c6ae552a60202e45fffb965dfef09d610ffc9b3b +settings.md: 1cabfae5d8dc72a9cd79341d250ee79820693872 +settings.zh.md: d63a1384646fa38199e7d65e9f0504f0440597be diff --git a/docs/core-data-structures/settings.md b/docs/core-data-structures/settings.md index b1e66b55c2..1cabfae5d8 100644 --- a/docs/core-data-structures/settings.md +++ b/docs/core-data-structures/settings.md @@ -84,6 +84,11 @@ interface SettingsDescriptor { schema: unknown /** Current resolved value. */ value: unknown + /** + * Monotonic revision of the raw user section this descriptor was read at. + * Send it back as `expectedRevision` on a write to refuse a stale one. + */ + revision: number /** Registrant's composition `base` layer (detached), when one was declared. */ base?: unknown /** @@ -98,6 +103,21 @@ interface SettingsDescriptor { } ``` +A caller that holds only the redacted descriptor cannot safely rebuild a section, so removals travel as path ops instead. Each descriptor also carries a `revision` over the raw section; a write may send it back as `expectedRevision`, and one that no longer matches is refused rather than applied over the writer that landed first. +```ts type-equiv +/** + * One path-addressed edit to a namespace's user section. Path mutation exists + * for a caller holding an INCOMPLETE view of the section — a configuration UI + * reads the redacted descriptor, which by construction never received the + * `role('secret')` fields. Such a caller can name the field it means without + * restating the section: a wholesale `replace` rebuilt from a redacted + * document silently deletes every secret the wire never returned. + */ +type SettingsPathOp = + | { op: 'set'; path: readonly string[]; value: unknown } + | { op: 'unset'; path: readonly string[] } +``` + ```ts type-equiv /** Options for {@link Settings.describe}. */ interface SettingsDescribeOptions { diff --git a/docs/core-data-structures/settings.zh.md b/docs/core-data-structures/settings.zh.md index c6ae552a60..d63a138464 100644 --- a/docs/core-data-structures/settings.zh.md +++ b/docs/core-data-structures/settings.zh.md @@ -84,6 +84,11 @@ interface SettingsDescriptor { schema: unknown /** Current resolved value. */ value: unknown + /** + * Monotonic revision of the raw user section this descriptor was read at. + * Send it back as `expectedRevision` on a write to refuse a stale one. + */ + revision: number /** Registrant's composition `base` layer (detached), when one was declared. */ base?: unknown /** @@ -98,6 +103,21 @@ interface SettingsDescriptor { } ``` +只持有脱敏 descriptor 的调用方无法安全地重建分节,因此删除改以路径 op 传递。每个 descriptor 还携带针对原始分节的 `revision`;写入可以把它作为 `expectedRevision` 送回,不再匹配的写入会被拒绝,而不是覆盖在先落地的那个写方之上。 +```ts type-equiv +/** + * One path-addressed edit to a namespace's user section. Path mutation exists + * for a caller holding an INCOMPLETE view of the section — a configuration UI + * reads the redacted descriptor, which by construction never received the + * `role('secret')` fields. Such a caller can name the field it means without + * restating the section: a wholesale `replace` rebuilt from a redacted + * document silently deletes every secret the wire never returned. + */ +type SettingsPathOp = + | { op: 'set'; path: readonly string[]; value: unknown } + | { op: 'unset'; path: readonly string[] } +``` + ```ts type-equiv /** Options for {@link Settings.describe}. */ interface SettingsDescribeOptions { diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 5c6f0e5e8d..d2ca9cea49 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -38,7 +38,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:103`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) | -| `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:130`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy`, [`settings`](../packages/settings/settings) | +| `settings/document-updated` | `emit` | [`packages/settings/settings/src/index.ts:150`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` | +| `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:137`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:188`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | [`tui`](../packages/ui/tui) | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:140`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:114`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index c03947edc6..70657d55d7 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/connection/README.md -README.md: 6aa4daa6d4440996e8037f658e50e954278a9e6d -README.zh.md: 12f83f7c1757cbff5613865ac75232b4ab1e0cf6 +README.md: d2fda9f15125915594259e01e5b153609ceb21bb +README.zh.md: 669ae760693b4d98ee873ee5fe323554f58e7ca5 diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index 6aa4daa6d4..d2fda9f151 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, `settings.update`, `settings.replace`, `credentials.set`, `credentials.unset`) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3. +Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`, reads included, since describing returns the exposed configuration and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3. ## /api browser-trust fence diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index 12f83f7c17..669ae76069 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`、`settings.update`、`settings.replace`、`credentials.set`、`credentials.unset`)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。 +协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`,读取也在内,因为 describe 会返回已暴露的配置,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。 ## /api 浏览器信任栅栏 diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index 2ed688d555..f8e51817fa 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -156,9 +156,9 @@ export class FakeApiClient implements IApiClient { readonly settings: IApiClient['settings'] = { describe: payload => this.record('settings.describe', payload, Promise.resolve(ok({ writable: true, namespaces: [] }))), - update: payload => this.record('settings.update', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [] }))), - replace: payload => this.record('settings.replace', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [] }))), - mutate: payload => this.record('settings.mutate', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [] }))), + update: payload => this.record('settings.update', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))), + replace: payload => this.record('settings.replace', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))), + mutate: payload => this.record('settings.mutate', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))), } readonly credentials: IApiClient['credentials'] = { diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index 480a6f034d..7144bd937a 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -183,9 +183,9 @@ export class FakeApiClient implements IApiClient { readonly settings: IApiClient['settings'] = { describe: payload => this.record('settings.describe', payload, Promise.resolve(ok({ writable: true, namespaces: [] }))), - update: payload => this.record('settings.update', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [] }))), - replace: payload => this.record('settings.replace', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [] }))), - mutate: payload => this.record('settings.mutate', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [] }))), + update: payload => this.record('settings.update', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))), + replace: payload => this.record('settings.replace', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))), + mutate: payload => this.record('settings.mutate', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))), } readonly credentials: IApiClient['credentials'] = { diff --git a/packages/client/schema-form/README.i18n.yaml b/packages/client/schema-form/README.i18n.yaml index 522b7a8ddf..f6e939d878 100644 --- a/packages/client/schema-form/README.i18n.yaml +++ b/packages/client/schema-form/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/schema-form/README.md -README.md: 23e69f80914b400a77c036192f564d32bc148310 -README.zh.md: b26593d971d0c53d1fd8d0778200914a90b9b891 +README.md: 5dcef89cbffc8b03c3f2d874e870fa9767360d3c +README.zh.md: a82acb7d85005da25858fb17cf42b49f06ae59db diff --git a/packages/client/schema-form/README.md b/packages/client/schema-form/README.md index 23e69f8091..5dcef89cbf 100644 --- a/packages/client/schema-form/README.md +++ b/packages/client/schema-form/README.md @@ -18,5 +18,6 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work +- **Rehydration executes the served envelope** — `rehydrateSchema` reconstructs a live schemastery validator, and schemastery revives serialized callbacks through `new Function`, so the schema envelope is executable content rather than inert data. That is acceptable only because the envelope comes from the same host that serves the page; a browser schema protocol should carry a description the client cannot execute, which is deferred with the settings seam's [wire-boundary work](../../settings/settings/README.md#known-limitations-and-deferred-work). - **Validation is draft-level, not per-field** — `validateDraft` reports schemastery's first failure message (which names the `$.path`); per-field error mapping is deferred until a consumer needs it. - **No generic renderer** — a schema-driven form component was built and then replaced by the hand-written Models editor ([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md)); if a future page needs to edit arbitrary sections, it starts from these helpers, not from a resurrected generic renderer, unless the note's trade-off changes. diff --git a/packages/client/schema-form/README.zh.md b/packages/client/schema-form/README.zh.md index b26593d971..a82acb7d85 100644 --- a/packages/client/schema-form/README.zh.md +++ b/packages/client/schema-form/README.zh.md @@ -18,5 +18,6 @@ ## Known Limitations and Deferred Work +- **重建 schema 会执行所收到的信封**——`rehydrateSchema` 会重建一个活的 schemastery 校验器,而 schemastery 通过 `new Function` 复活序列化过的 callback,因此 schema 信封是可执行内容,而非惰性数据。这只有在信封来自提供该页面的同一 host 时才可接受;面向浏览器的 schema 协议应当传递客户端无法执行的描述,此项与 settings seam 的[协议边界工作](../../settings/settings/README.md#known-limitations-and-deferred-work)一并暂缓。 - **校验是草稿级的,而非逐字段**——`validateDraft` 报告 schemastery 的第一条失败消息(其中会点名 `$.path`);逐字段的报错映射延后到出现需要它的消费方再做。 - **没有通用渲染器**——一个 schema 驱动的表单组件曾被构建出来,随后被手写的 Models 编辑器取代([Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md));若未来有页面需要编辑任意分节,起点是这些辅助函数,而不是复活后的通用渲染器——除非该 note 的权衡发生变化。 diff --git a/packages/client/ui-models/README.i18n.yaml b/packages/client/ui-models/README.i18n.yaml index dbb7a2b65d..c6c7df8f70 100644 --- a/packages/client/ui-models/README.i18n.yaml +++ b/packages/client/ui-models/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-models/README.md -README.md: 7ee55f5232049806be6d5256d0e2dbbe948a6de5 -README.zh.md: afb896452dd21c79ffe9ebd1ba473125595bff2a +README.md: 30eb4a3a10caf961d50517048ca05490dac02799 +README.zh.md: b58a0adb388d247ade7fe375f5daba2964467a61 diff --git a/packages/client/ui-models/README.md b/packages/client/ui-models/README.md index 7ee55f5232..30eb4a3a10 100644 --- a/packages/client/ui-models/README.md +++ b/packages/client/ui-models/README.md @@ -6,7 +6,7 @@ Models settings section plugin: the provider configuration page. It joins three Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), plus `reasoningEffort` (deepseek) or `reasoning` (pi-ai); every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base). -Apply semantics mirror the settings seam: an edit without removals lands as a minimal `settings.update` merge patch, while clearing a fold field back to inherited or deleting a row lands through `settings.replace` of the whole user section so removals actually take effect — safe wholesale, because the section stores key references, never key values. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, 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 row. The page only ever holds the REDACTED descriptor, so it names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. Each write carries the `revision` the card opened at, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict` and the card asks the user to reopen instead of replaying its stale snapshot. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling. ## Model Experience @@ -18,8 +18,7 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **A reset can drop a stored literal secret in the same subtree** — a replace-carried removal cannot re-supply secrets the wire never returned; store keys behind `credentials.*` references (the product default) and the case cannot arise. - **Only the API key and the curated fold fields are editable on the card** — the hand-written editor traded schema-generic field coverage for the mockup layout ([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md)); advanced fields (`models`, retry policy, timeouts…) are edited in `settings.yaml`, which the fold points at. A profile schema without the conventional fields renders the hint alone, and the two curated layouts key on the `llm-deepseek`/`llm-pi-ai` namespaces by name. -- **Deleting a row leaves its stored key in `.env`** — removal replaces the settings profile but deliberately does not unset the derived credential; re-adding the provider finds the key already configured. An explicit key-removal control is deferred. +- **Deleting a row leaves its stored key in `.env`** — removal unsets the settings profile but deliberately does not unset the derived credential; re-adding the provider finds the key already configured. An explicit key-removal control is deferred. - **No per-provider model listing on the page** — the picker surfaces models; this page shows route state only. A models preview per row is deferred until a consumer needs it. - **Undeclared live routes render nowhere** — a route registered without a configurable-provider declaration has no settings address; it stays visible in pickers but not on this page's rows. diff --git a/packages/client/ui-models/README.zh.md b/packages/client/ui-models/README.zh.md index afb896452d..b58a0adb38 100644 --- a/packages/client/ui-models/README.zh.md +++ b/packages/client/ui-models/README.zh.md @@ -6,7 +6,7 @@ 行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),另加 `reasoningEffort`(deepseek)或 `reasoning`(pi-ai);其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base)。 -「应用」语义与 settings seam 呈镜像:不含删除的编辑以最小的 `settings.update` 合并 patch 落地,把折叠区字段清回继承值或删除整行则经对整个用户分节的 `settings.replace` 落地,使删除真正生效——整体替换是安全的,因为该分节存的是密钥引用,从不存密钥值。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。 +每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除整行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它点名自己看得见的字段,而不是重建分节:一个它从未收到过的已存字面机密不会被任何 op 提及,也就得以留存。每次写入都携带该卡片打开时的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝,卡片会请用户重新打开,而不是把自己过期的快照重放上去。 ## 模型体验 @@ -18,8 +18,7 @@ ## 已知限制与暂缓事项 -- **重置可能丢弃同一子树中已存储的字面 secret**:经 replace 承载的删除无法重新提供协议从未返回过的 secret;把密钥放在 `credentials.*` 引用背后(产品默认做法),该情形便不会出现。 - **卡片上可编辑的只有 API 密钥与精选折叠区字段**:手写编辑器用 schema 通用的字段覆盖面换来了设计稿上的布局([Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md));进阶字段(`models`、重试策略、超时……)在 `settings.yaml` 中编辑,折叠区会指向它。不带这些约定字段的 profile schema 只渲染该提示,两套精选布局则以 `llm-deepseek`/`llm-pi-ai` 这两个 namespace 的名字为键。 -- **删除一行会把它已存储的密钥留在 `.env` 里**:删除替换的是 settings profile,却刻意不清除那条派生凭据;重新添加该提供方时会发现密钥已配置。显式的密钥移除控件暂缓。 +- **删除一行会把它已存储的密钥留在 `.env` 里**:删除取消设置的是 settings profile,却刻意不清除那条派生凭据;重新添加该提供方时会发现密钥已配置。显式的密钥移除控件暂缓。 - **页面上没有逐提供方的模型列表**:模型由选择器呈现;本页只展示路由状态。逐行的模型预览暂缓,待有消费方需要时再实现。 - **未声明的存活路由无处渲染**:未附带可配置提供方声明即注册的路由没有 settings 地址;它在各选择器中仍然可见,但不会出现在本页的行里。 diff --git a/packages/client/ui-models/src/client/ProviderEditor.tsx b/packages/client/ui-models/src/client/ProviderEditor.tsx index 7178a75de2..47bf6a9286 100644 --- a/packages/client/ui-models/src/client/ProviderEditor.tsx +++ b/packages/client/ui-models/src/client/ProviderEditor.tsx @@ -127,6 +127,10 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { const [keyState, setKeyState] = useState(undefined) const [busy, setBusy] = useState(false) const [failure, setFailure] = useState(undefined) + // The revision this card opened at. A write carrying it is refused if + // anything else — another tab, an external edit of settings.yaml — moved the + // namespace meanwhile, instead of silently overwriting that change. + const [openedAt] = useState(() => namespace.revision) const root = useMemo(() => rehydrateSchema(namespace.schema), [namespace.schema]) const node = useMemo(() => nodeAtPath(root, settingsPath), [root, settingsPath]) const fallback = getPath(namespace.value, settingsPath) @@ -175,8 +179,12 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { } const ops = pathOps(settingsPath, original, next) if (ops.length > 0) { - const response = await api.settings.mutate({ ns, ops }) - if (!response.result.ok) return response.result.error.message + const response = await api.settings.mutate({ ns, ops, expectedRevision: openedAt }) + if (!response.result.ok) { + return response.result.error.code === 'settings-conflict' + ? t('conflict') + : response.result.error.message + } } if (keyDraft.length > 0) { const stored = await api.credentials.set({ ref: keyRef, value: keyDraft }) diff --git a/packages/client/ui-models/src/client/locales.ts b/packages/client/ui-models/src/client/locales.ts index 32f37b1210..5671384ab6 100644 --- a/packages/client/ui-models/src/client/locales.ts +++ b/packages/client/ui-models/src/client/locales.ts @@ -16,6 +16,7 @@ export const en = { applying: 'Applying…', readOnly: 'The settings document is read-only in this deployment.', loadFailed: 'Loading the provider directory failed', + conflict: 'Someone else changed these settings while this card was open. Close it and reopen to edit the current values.', retry: 'Retry', keyInput: 'API key', keyPlaceholder: 'Enter your API key', @@ -45,6 +46,7 @@ export const zh: typeof en = { applying: '保存中…', readOnly: '当前部署的设置文档为只读。', loadFailed: '加载提供方目录失败', + conflict: '这张卡片打开期间,这些设置已被其他地方改动。请关闭后重新打开,在当前值上编辑。', retry: '重试', keyInput: 'API 密钥', keyPlaceholder: '输入 API 密钥', diff --git a/packages/client/ui-models/tests/components.spec.tsx b/packages/client/ui-models/tests/components.spec.tsx index 24980bbccf..974f400999 100644 --- a/packages/client/ui-models/tests/components.spec.tsx +++ b/packages/client/ui-models/tests/components.spec.tsx @@ -44,6 +44,7 @@ function wireNamespaces(): SettingsNamespaceView[] { user: { reasoningEffort: 'high' }, applies: 'live', secrets: [{ path: ['apiKey'], set: false }], + revision: 0, }, { ns: 'llm-plain', @@ -53,6 +54,7 @@ function wireNamespaces(): SettingsNamespaceView[] { value: {}, applies: 'live', secrets: [], + revision: 0, }, { ns: 'llm-pi-ai', @@ -61,6 +63,7 @@ function wireNamespaces(): SettingsNamespaceView[] { user: { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://proxy', headers: { 'X-Team': 'a' } }, zombie: {} } }, applies: 'live', secrets: [{ path: ['token'], set: false }, { path: ['providers', 'openai', 'apiKey'], set: false }], + revision: 0, }, ] } @@ -225,6 +228,7 @@ describe('ModelsSection', () => { expect(mutate.mock.calls[0]?.[0]).toEqual({ ns: 'llm-deepseek', ops: [{ op: 'set', path: ['baseURL'], value: 'https://next2' }], + expectedRevision: 0, }) }) @@ -243,6 +247,7 @@ describe('ModelsSection', () => { expect(mutate.mock.calls[0]?.[0]).toEqual({ ns: 'llm-deepseek', ops: [{ op: 'unset', path: ['reasoningEffort'] }], + expectedRevision: 0, }) }) @@ -254,6 +259,7 @@ describe('ModelsSection', () => { value: {}, applies: 'live', secrets: [], + revision: 0, } const { ProviderEditor } = await import('../src/client/ProviderEditor.tsx') render( { expect(mutate.mock.calls[0]?.[0]).toEqual({ ns: 'llm-pi-ai', ops: [{ op: 'set', path: ['providers', 'openai', 'reasoning'], value: 'xhigh' }], + expectedRevision: 0, }) }) @@ -330,6 +337,7 @@ describe('ModelsSection', () => { expect(mutate.mock.calls[0]?.[0]).toEqual({ ns: 'llm-pi-ai', ops: [{ op: 'set', path: ['providers', 'anthropic', 'apiKeyEnv'], value: 'ANTHROPIC_API_KEY' }], + expectedRevision: 0, }) await waitFor(() => { expect(set).toHaveBeenCalledWith({ ref: 'ANTHROPIC_API_KEY', value: 'sk-ant' }) }) }) @@ -363,6 +371,19 @@ describe('ModelsSection', () => { expect(set).not.toHaveBeenCalled() }) + it('tells the user to reopen when another writer moved the namespace first', async () => { + // The stale-draft overwrite: two tabs open the same card, the other saves, + // and this one must be refused rather than replay its opening snapshot. + const { set } = await mountSection({ + mutate: vi.fn(() => Promise.resolve(fail('changed since it was read', 'settings-conflict'))), + }) + fireEvent.click(screen.getByText(en.customized)) + fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://mine' } }) + fireEvent.click(screen.getByText(en.apply)) + await screen.findByText(en.conflict) + expect(set).not.toHaveBeenCalled() + }) + it('surfaces a shadowed credential write on the card', async () => { await mountSection({ set: vi.fn(() => Promise.resolve(fail('credentials: DEEPSEEK_API_KEY is shadowed by the read-only environment', 'credential-rejected'))), diff --git a/packages/client/ui-models/tests/store.spec.ts b/packages/client/ui-models/tests/store.spec.ts index eadeb0d913..8d8274bbc3 100644 --- a/packages/client/ui-models/tests/store.spec.ts +++ b/packages/client/ui-models/tests/store.spec.ts @@ -26,6 +26,7 @@ const NAMESPACES = [ base: { baseURL: 'https://base' }, applies: 'live' as const, secrets: [{ path: ['apiKey'], set: false }], + revision: 0, }, { ns: 'llm-pi-ai', @@ -34,6 +35,7 @@ const NAMESPACES = [ user: { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY' } } }, applies: 'live' as const, secrets: [], + revision: 0, }, ] @@ -151,6 +153,7 @@ describe('edge joins', () => { value: { providers: { weird: 'oops' } }, applies: 'live' as const, secrets: [], + revision: 0, }] as never, })), providers: () => Promise.resolve(ok({ @@ -170,7 +173,7 @@ describe('edge joins', () => { const { face, seenRefs } = api({ describeSettings: () => Promise.resolve(ok({ writable: true, - namespaces: [{ ns: 'llm-pi-ai', schema: {}, value: { providers: {} }, applies: 'live' as const, secrets: [] }] as never, + namespaces: [{ ns: 'llm-pi-ai', schema: {}, value: { providers: {} }, applies: 'live' as const, secrets: [], revision: 0 }] as never, })), providers: () => Promise.resolve(ok({ providers: [ diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 409ca4d940..beaf70df2e 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -795,12 +795,16 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ jsDoc: '/**\n * Read one registered namespace\'s resolved value.\n * @param ns - the namespace to read.\n * @returns the resolved value, or `undefined` while unregistered.\n */', }, { - signature: 'async update(ns: SettingsNamespace, patch: object): Promise', - jsDoc: '/**\n * Merge a patch into one registered namespace\'s user layer, validate the\n * resolved candidate, persist through the provider, then commit and emit.\n * A validation failure rejects before anything is persisted. Writes to one\n * namespace are serialized: concurrent updates apply in call order, each\n * merging over the previous write\'s committed section.\n * @param ns - the registered namespace to update.\n * @param patch - plain-object patch over the user section.\n */', + signature: 'async update(ns: SettingsNamespace, patch: object, expectedRevision?: number): Promise', + jsDoc: '/**\n * Merge a patch into one registered namespace\'s user layer, validate the\n * resolved candidate, persist through the provider, then commit and emit.\n * A validation failure rejects before anything is persisted. Writes to one\n * namespace are serialized: concurrent updates apply in call order, each\n * merging over the previous write\'s committed section.\n * @param ns - the registered namespace to update.\n * @param patch - plain-object patch over the user section.\n * @param expectedRevision - the descriptor `revision` the caller read; a\n * namespace that moved past it rejects with {@link SettingsConflictError}.\n */', }, { - signature: 'async replace(ns: SettingsNamespace, section: object): Promise', - jsDoc: '/**\n * Replace one registered namespace\'s user section wholesale, validate,\n * persist, then commit and emit. Keys absent from `section` fall back to the\n * composition `base` and schema defaults — this is the removal/reset path a\n * merge-only patch cannot express (`replace({})` re-inherits everything).\n * @param ns - the registered namespace to replace.\n * @param section - the complete next user section.\n */', + signature: 'async replace(ns: SettingsNamespace, section: object, expectedRevision?: number): Promise', + jsDoc: '/**\n * Replace one registered namespace\'s user section wholesale, validate,\n * persist, then commit and emit. Keys absent from `section` fall back to the\n * composition `base` and schema defaults — this is the removal/reset path a\n * merge-only patch cannot express (`replace({})` re-inherits everything).\n * @param ns - the registered namespace to replace.\n * @param section - the complete next user section.\n * @param expectedRevision - the descriptor `revision` the caller read; a\n * namespace that moved past it rejects with {@link SettingsConflictError}.\n */', + }, + { + signature: 'async mutate(ns: SettingsNamespace, ops: readonly SettingsPathOp[], expectedRevision?: number): Promise', + jsDoc: '/**\n * Apply path-addressed edits to one registered namespace\'s user section,\n * validate, persist, then commit and emit. The ops are applied to the\n * section as it stands when the write reaches the front of the queue, so a\n * caller never has to restate fields it did not touch — and, crucially,\n * cannot delete fields it never saw. This is the write path for any caller\n * holding a redacted view; `replace` remains the wholesale reset.\n * @param ns - the registered namespace to edit.\n * @param ops - ordered path edits; later ops observe earlier ones.\n * @param expectedRevision - the descriptor `revision` the caller read; a\n * namespace that moved past it rejects with {@link SettingsConflictError}.\n */', }, ], }, @@ -1385,6 +1389,13 @@ export const EVENT_API: readonly EventApiEntry[] = [ jsDoc: '/**\n * Awaited parallel durability checkpoint: every listener runs and the\n * caller awaits all of them, with no waterfall veto. Dispatch through\n * {@link SessionStore.flush}. Scope-filtered dispatch\n * (`@deepseek-ai/dsh-scope`) reuses the session\'s owner scope.\n * @param session - the session whose buffered events must reach durable storage.\n * @dshScopeScan unsupported\n * @mode parallel\n */', summary: 'Awaited parallel durability checkpoint: every listener runs and the caller awaits all of them, with no waterfall veto.', }, + { + name: 'settings/document-updated', + mode: 'emit', + signature: '\'settings/document-updated\'(ns: SettingsNamespace, revision: number): void', + jsDoc: '/**\n * One registered namespace\'s RAW user section changed, whether or not the\n * resolved value did. `settings/updated` is the consumer-facing event and\n * stays deep-equal-gated; this one exists for configuration surfaces,\n * which must learn that a field went from inherited to overridden (same\n * resolved value, different meaning) and that their held revision is\n * stale. Listener containment matches `settings/updated`.\n * @param ns - the namespace whose stored section changed.\n * @param revision - the namespace\'s new revision.\n * @mode emit\n */', + summary: 'One registered namespace\'s RAW user section changed, whether or not the resolved value did.', + }, { name: 'settings/updated', mode: 'emit', @@ -2482,12 +2493,16 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SettingsDescriptor', - declaration: 'export interface SettingsDescriptor {\n ns: SettingsNamespace;\n schema: unknown;\n value: unknown;\n base?: unknown;\n user?: unknown;\n applies: SettingsApplies;\n secrets?: RedactedSecret[];\n}', + declaration: 'export interface SettingsDescriptor {\n ns: SettingsNamespace;\n schema: unknown;\n value: unknown;\n revision: number;\n base?: unknown;\n user?: unknown;\n applies: SettingsApplies;\n secrets?: RedactedSecret[];\n}', }, { name: 'SettingsNamespace', declaration: 'export type SettingsNamespace = Branded<\'SettingsNamespace\'>;', }, + { + name: 'SettingsPathOp', + declaration: 'export type SettingsPathOp = {\n op: \'set\';\n path: readonly string[];\n value: unknown;\n} | {\n op: \'unset\';\n path: readonly string[];\n};', + }, { name: 'SettingsRegisterOptions', declaration: 'export interface SettingsRegisterOptions {\n base?: Partial;\n applies?: SettingsApplies;\n}', diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 5763e6ae30..3bb95752b7 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: bab1fef86e5b622183119d3d820c89cd146f8c09 -README.zh.md: d1867c816eace26c8710cdbaedee4694a2c7ab96 +README.md: fc588048ef0cfdf030a67877094d5db4499df270 +README.zh.md: 1211a13994e1009b549cdda5fa6e1772a508d39e diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index bab1fef86e..fc588048ef 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -26,7 +26,7 @@ Directory picking delegates to the composed `ctx.directoryPicker` backend ([the 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 browser's user-selected model-reference path, so it returns only skills that are both model-invocable and user-invocable; this domain has no direct skill-loading RPC. `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; the carrier's request signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. -The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. `settings.describe` serves every registered namespace with its serialized schemastery schema plus redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden) and the `secrets` slot list; `settings.update`/`settings.replace` write the user layer and answer with the namespace's new redacted view, folding every seam refusal 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` patch 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. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/updated` passthrough — RPC writes and external `settings.yaml` edits alike), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` (`llm/adapters-updated` passthrough). The browser carrier restricts the four write methods (`settings.update`/`settings.replace`/`credentials.set`/`credentials.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 exactly the namespaces a registered configurable provider addresses (`ctx.llm.listConfigurableProviders()`): the seam is general, but this plane is the model-provider surface, so a namespace nothing in the directory names is neither described nor writable here and answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed 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, and the section's `revision`. `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. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired both by `llm/adapters-updated` and by a change to an exposed provider namespace, whose settings carry that provider's catalog and endpoint. The browser carrier restricts the whole configuration plane, reads included (`settings.describe`/`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) diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index d1867c816e..1211a13994 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -26,7 +26,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr `command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于浏览器中由用户选择的模型引用路径,因此仅返回模型和用户均可调用的 skill;该领域没有直接加载 skill 的 RPC。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载;载体的请求信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 -`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。`settings.describe` 为每个已注册 namespace 提供其序列化 schemastery schema,外加脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)与 `secrets` 槽位列表;`settings.update`/`settings.replace` 写入用户层,并以该 namespace 的新脱敏视图作答,把每种 seam 拒绝折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update` patch 或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/updated` 透传——RPC 写入与外部 `settings.yaml` 编辑一视同仁)、`host/credentials-changed {ref}`(只带引用名,绝不带值)与 `host/models-changed`(`llm/adapters-updated` 透传)。浏览器载体将四个写方法(`settings.update`/`settings.replace`/`credentials.set`/`credentials.unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。 +`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域只服务于已注册可配置提供方所指向的那些 namespace(`ctx.llm.listConfigurableProviders()`):seam 本身是通用的,但这个面是模型提供方表层,因此目录中无人点名的 namespace 在这里既不会被描述也不可写入,只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表,以及该分节的 `revision`。`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` 则是与会话无关的目录。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它既由 `llm/adapters-updated` 触发,也由某个已暴露提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点。浏览器载体把整个配置面(含读取:`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。 ## 载体层(`/client` + 根路径) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index c707f9fa71..e499893040 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -41,7 +41,7 @@ import type {} from '@deepseek-ai/dsh-skill' // The settings/credentials seams: brand guards run at this wire boundary; the // service reads stay optional (`ctx.get`) so a composition without either // provider still serves every other domain. -import { settingsNamespace } from '@deepseek-ai/dsh-settings' +import { SettingsConflictError, settingsNamespace } from '@deepseek-ai/dsh-settings' import type { SettingsDescriptor, SettingsNamespace, SettingsPathOp } from '@deepseek-ai/dsh-settings' import { credentialRef } from '@deepseek-ai/dsh-credentials' // Value edge: the rename impl narrows the title service's validation failure; the import also resolves `ctx.get('sessionTitle')`. @@ -1007,6 +1007,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro ...descriptor.user === undefined ? {} : { user: descriptor.user }, applies: descriptor.applies, secrets: (descriptor.secrets ?? []).map(secret => ({ path: [...secret.path], set: secret.set })), + revision: descriptor.revision, } } @@ -1044,14 +1045,26 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro ns: string, mode: 'update' | 'replace' | 'mutate', section: object, + expectedRevision?: number, ): Promise> { const settings = ctx.get('settings') if (settings === undefined) return err(request, settingsAbsent()) - const rejected = (error: unknown): RpcResponse => err(request, { - code: 'settings-rejected', - message: error instanceof Error ? error.message : String(error), - details: { ns }, - }) + const rejected = (error: unknown): RpcResponse => { + // A stale writer is its own outcome, not a malformed request: the client + // must re-read and re-apply rather than treat the write as invalid. + if (error instanceof SettingsConflictError) { + return err(request, { + code: 'settings-conflict', + message: error.message, + details: { ns, expected: error.expected, actual: error.actual }, + }) + } + return err(request, { + code: 'settings-rejected', + message: error instanceof Error ? error.message : String(error), + details: { ns }, + }) + } let branded: SettingsNamespace try { branded = settingsNamespace(ns) @@ -1062,9 +1075,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } if (!exposedNamespaces().has(ns)) return notExposed(request, ns) try { - if (mode === 'update') await settings.update(branded, section) - else if (mode === 'replace') await settings.replace(branded, section) - else await settings.mutate(branded, section as SettingsPathOp[]) + if (mode === 'update') await settings.update(branded, section, expectedRevision) + else if (mode === 'replace') await settings.replace(branded, section, expectedRevision) + else await settings.mutate(branded, section as SettingsPathOp[], expectedRevision) } catch (error: unknown) { return rejected(error) } @@ -1667,9 +1680,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro .map(namespaceView), })) }, - update: request => settingsWrite(request, request.payload.ns, 'update', request.payload.patch), - replace: request => settingsWrite(request, request.payload.ns, 'replace', request.payload.section), - mutate: request => settingsWrite(request, request.payload.ns, 'mutate', request.payload.ops), + update: request => settingsWrite(request, request.payload.ns, 'update', request.payload.patch, request.payload.expectedRevision), + replace: request => settingsWrite(request, request.payload.ns, 'replace', request.payload.section, request.payload.expectedRevision), + mutate: request => settingsWrite(request, request.payload.ns, 'mutate', request.payload.ops, request.payload.expectedRevision), }, credentials: { @@ -1884,8 +1897,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro ctx.on('commands/change', () => { queue.push(frame({ type: 'host/commands-changed' })) }), - ctx.on('settings/updated', (ns) => { + ctx.on('settings/document-updated', (ns) => { + // The RAW-section event, not the resolved one: a field going from + // inherited to overridden leaves the resolved value equal, and a + // configuration client still has to re-read (its held revision is + // stale, and the field's meaning changed). queue.push(frame({ type: 'host/settings-changed', ns: String(ns) })) + // A provider's own settings carry its model catalog and endpoint, + // so a change there invalidates the model list even when the route + // set is untouched — `llm/adapters-updated` alone misses it. + if (exposedNamespaces().has(String(ns))) queue.push(frame({ type: 'host/models-changed' })) }), ctx.on('credentials/updated', (ref) => { queue.push(frame({ type: 'host/credentials-changed', ref: String(ref) })) diff --git a/packages/host/apiproxy/src/api/rpc.schema.ts b/packages/host/apiproxy/src/api/rpc.schema.ts index 85ecc1618b..232668bf0c 100644 --- a/packages/host/apiproxy/src/api/rpc.schema.ts +++ b/packages/host/apiproxy/src/api/rpc.schema.ts @@ -52,6 +52,7 @@ export const rpcErrorSchema: z.ZodType = z.discriminatedUnion('code', z.object({ code: z.literal('unknown-command'), message: z.string(), details: z.object({}) }), z.object({ code: z.literal('settings-rejected'), message: z.string(), details: z.object({ ns: z.string() }) }), z.object({ code: z.literal('settings-not-exposed'), message: z.string(), details: z.object({ ns: z.string() }) }), + z.object({ code: z.literal('settings-conflict'), message: z.string(), details: z.object({ ns: z.string(), expected: z.number(), actual: z.number() }) }), z.object({ code: z.literal('credential-rejected'), message: z.string(), details: z.object({ ref: z.string() }) }), z.object({ code: z.literal('title-invalid'), message: z.string(), details: z.object({ sessionId: z.string() }) }), z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }), diff --git a/packages/host/apiproxy/src/api/rpc.ts b/packages/host/apiproxy/src/api/rpc.ts index d215f7f6fa..6fcef689c8 100644 --- a/packages/host/apiproxy/src/api/rpc.ts +++ b/packages/host/apiproxy/src/api/rpc.ts @@ -61,6 +61,12 @@ export interface RpcErrorDetailsMap { * it; the message names the namespace. */ 'settings-not-exposed': { ns: string } + /** + * A settings write carried an `expectedRevision` the namespace has already + * moved past: another writer (tab, editor, or an external file edit) landed + * first. The details carry both revisions so a client can re-read and retry. + */ + 'settings-conflict': { ns: string; expected: number; actual: number } /** A credential write was refused (read-only shadowing layer or storage failure); the message is the seam's own text. */ 'credential-rejected': { ref: string } 'title-invalid': { sessionId: SessionId } diff --git a/packages/host/apiproxy/src/api/settings.schema.ts b/packages/host/apiproxy/src/api/settings.schema.ts index 2def419e1b..56ac16f93d 100644 --- a/packages/host/apiproxy/src/api/settings.schema.ts +++ b/packages/host/apiproxy/src/api/settings.schema.ts @@ -23,6 +23,7 @@ export const settingsNamespaceViewSchema = z.object({ user: z.unknown().optional(), applies: z.union([z.literal('live'), z.literal('restart')]), secrets: z.array(settingsSecretViewSchema), + revision: z.number(), }) satisfies z.ZodType> /** settings.describe request payload. */ @@ -38,6 +39,7 @@ export const settingsDescribeValueSchema = z.object({ export const settingsUpdateRequestSchema = z.object({ ns: z.string().min(1), patch: z.record(z.string(), z.unknown()), + expectedRevision: z.number().optional(), }) satisfies z.ZodType>> /** settings.update response value: the namespace's new redacted view. */ @@ -47,6 +49,7 @@ export const settingsUpdateValueSchema = settingsNamespaceViewSchema satisfies z export const settingsReplaceRequestSchema = z.object({ ns: z.string().min(1), section: z.record(z.string(), z.unknown()), + expectedRevision: z.number().optional(), }) satisfies z.ZodType>> /** One path-addressed edit of settings.mutate. */ @@ -59,6 +62,7 @@ export const settingsPathOpSchema = z.discriminatedUnion('op', [ export const settingsMutateRequestSchema = z.object({ ns: z.string().min(1), ops: z.array(settingsPathOpSchema), + expectedRevision: z.number().optional(), }) satisfies z.ZodType>> /** settings.mutate response value: the namespace's new redacted view. */ diff --git a/packages/host/apiproxy/src/api/settings.ts b/packages/host/apiproxy/src/api/settings.ts index 7bad8c566e..30327f19d1 100644 --- a/packages/host/apiproxy/src/api/settings.ts +++ b/packages/host/apiproxy/src/api/settings.ts @@ -32,6 +32,12 @@ export interface SettingsNamespaceView { applies: 'live' | 'restart' /** Every schema-declared secret slot with its configured state. */ secrets: SettingsSecretView[] + /** + * Monotonic revision of the raw user section this view was read at. Send it + * back as `expectedRevision` on a write so a stale editor is refused rather + * than silently overwriting a concurrent change. + */ + revision: number } /** @@ -59,7 +65,7 @@ export interface SettingsApi { * merge preserves the stored value. Responds with the namespace's new * redacted view; a schema or storage rejection is `settings-rejected`. */ - update(request: RpcRequest<{ ns: string; patch: object }>): Promise> + update(request: RpcRequest<{ ns: string; patch: object; expectedRevision?: number }>): Promise> /** * Replace one namespace's user section wholesale — the removal/reset path a @@ -68,7 +74,7 @@ export interface SettingsApi { * fold the descriptor's `user` layer (and re-supply any secret it wants to * keep) or accept the reset. */ - replace(request: RpcRequest<{ ns: string; section: object }>): Promise> + replace(request: RpcRequest<{ ns: string; section: object; expectedRevision?: number }>): Promise> /** * Apply path-addressed edits to one namespace's user section, resolved @@ -78,5 +84,7 @@ export interface SettingsApi { * returned cannot be deleted as a side effect. `replace` remains the * deliberate wholesale reset. */ - mutate(request: RpcRequest<{ ns: string; ops: SettingsPathOpView[] }>): Promise> + mutate( + request: RpcRequest<{ ns: string; ops: SettingsPathOpView[]; expectedRevision?: number }>, + ): Promise> } diff --git a/packages/host/apiproxy/tests/api-proxy-config.spec.ts b/packages/host/apiproxy/tests/api-proxy-config.spec.ts index badfad76b3..a505f72018 100644 --- a/packages/host/apiproxy/tests/api-proxy-config.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-config.spec.ts @@ -257,6 +257,40 @@ describe('settings domain', () => { .toBe('settings-not-exposed') }) + it('invalidates the model catalog when a provider namespace changes, and broadcasts a raw-only change', async () => { + // Editing `models` changes no route, so llm/adapters-updated never fires + // and an open model picker kept serving the old catalog. And storing an + // override equal to the resolved value emits nothing on settings/updated, + // so another tab never learned the field became overridden. + const ctx = await harness() + ctx.settings.register(NS, AdapterConfig, { base: { baseURL: 'https://base' } }) + const api = createApiProxy(ctx, DEFAULTS) + const frames = await collectHost(api, ['host/settings-changed', 'host/models-changed'], 2, async () => { + await api.settings.update(request({ ns: 'llm-deepseek', patch: { baseURL: 'https://base' } })) + }) + expect(frames).toEqual([ + { type: 'host/settings-changed', ns: 'llm-deepseek' }, + { type: 'host/models-changed' }, + ]) + // The resolved value never moved: base already said https://base. + expect(expectOk(await api.settings.describe(request({}))).namespaces[0]!.value) + .toEqual({ apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://base' }) + }) + + it('maps a stale expectedRevision to settings-conflict carrying both revisions', async () => { + const ctx = await harness() + ctx.settings.register(NS, AdapterConfig) + const api = createApiProxy(ctx, DEFAULTS) + const opened = expectOk(await api.settings.describe(request({}))).namespaces[0]!.revision + expect(expectOk(await api.settings.update(request({ ns: 'llm-deepseek', patch: { baseURL: 'https://first' }, expectedRevision: opened }))) + .revision).toBe(opened + 1) + const error = expectErr(await api.settings.update(request({ ns: 'llm-deepseek', patch: { baseURL: 'https://second' }, expectedRevision: opened }))) + expect(error.code).toBe('settings-conflict') + expect(error.details).toEqual({ ns: 'llm-deepseek', expected: opened, actual: opened + 1 }) + // The refused write changed nothing. + expect(expectOk(await api.settings.describe(request({}))).namespaces[0]!.user).toEqual({ baseURL: 'https://first' }) + }) + it('updates the user layer, answers with the new redacted view, and broadcasts the frame', async () => { const ctx = await harness() ctx.settings.register(NS, AdapterConfig, { base: { baseURL: 'https://base' } }) diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index dec295ae13..7728d69bf2 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -602,6 +602,7 @@ describe('config unary surface', () => { user: { baseURL: 'https://next' }, applies: 'live' as const, secrets: [{ path: ['apiKey'], set: true }], + revision: 0, } const providerRow = { provider: 'openai', diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index 65cf324cb3..cf2c01291e 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-deepseek/README.md -README.md: 11bed74b4952624208f23f093b787eb978cfef69 -README.zh.md: 5fa75ad3434fe9610ba8005d436af51fd7a134f9 +README.md: 46666459d524dab952d555cc7f196d45e53d606a +README.zh.md: 4210974c274513dc47383a23711baa6b14515307 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 11bed74b49..46666459d5 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -109,7 +109,7 @@ Loop-retained response blocks append to the next request and preserve its earlie ## Known Limitations and Deferred Work - **A settings `models` list replaces the composition list wholesale** — settings-layer merging is per-field, and arrays are one field; per-entry catalog merging would need a keyed shape. -- **`Config.apiKey` is schema-tagged `role('secret')` but not yet masked anywhere** — the settings `describe()` envelope returns values verbatim; the wire/UI layer that must redact secret-role fields ships with the settings RPC surface. +- **`Config.apiKey` is redacted on the wire but still a stored literal** — `describe({ redactSecrets: true })` strips it and reports the slot, so a configuration UI never receives the value; the key is nonetheless stored in the settings document rather than the credential store, so prefer `apiKeyEnv`. - **`tool_choice` is not mapped** — not part of the core vocabulary (MVP cut, shared with the pi-ai twin). - **Requests use raw `fetch`, not `@cordisjs/plugin-http`** — no shared proxy/interception configuration; adoption is deferred until a second adapter wants it (`TODO(http)`). - **Serialization flattens user and tool-result content to text blocks** — plugin-added block types are skipped, and empty tool output crosses the wire as the literal `(no output)`. diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index 5fa75ad343..4210974c27 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -109,7 +109,7 @@ loop 保留的响应块会追加到下一个请求,并保留其较早可复用 ## 已知限制与暂缓事项 - **settings 的 `models` 列表会整体替换组合列表**:settings 层按字段合并,而数组是单个字段;按条目合并 catalog 需要带键的形状。 -- **`Config.apiKey` 已在 schema 中标注 `role('secret')`,但尚未在任何地方脱敏**:settings 的 `describe()` 信封原样返回值;负责对 secret 角色字段脱敏的 wire/UI 层将随 settings RPC 面一起交付。 +- **`Config.apiKey` 在协议上已脱敏,但仍是一个已存的字面值**:`describe({ redactSecrets: true })` 会把它剥离并报告该槽位,配置 UI 因此永远收不到该值;但这个密钥仍存放在 settings 文档而非凭据存储中,所以请优先使用 `apiKeyEnv`。 - **未映射 `tool_choice`**:它不属于核心词汇(MVP 取舍,与 pi-ai twin 共享)。 - **请求使用原始 `fetch`,而非 `@cordisjs/plugin-http`**:没有共享 proxy/拦截配置;采用暂缓到第二个适配器需要该功能时(`TODO(http)`)。 - **序列化会将 user 与工具结果内容展平为文本块**:会跳过插件添加的块类型,空工具输出会以字面 `(no output)` 通过协议发送。 diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index cae68d9de9..2a1a47b253 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-pi-ai/README.md -README.md: 75442e2f1f6578ed458d05302b1cb6b063e26092 -README.zh.md: 3baed4e30bbce6ce21c52da79369ad096bbbb752 +README.md: e8c2682cbb72ca1ac6a5ad6b26bdf63f0695716b +README.zh.md: 5fb19ee1343e905352609d96e7f540c1a411b4d8 diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 75442e2f1f..e8c2682cbb 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -112,7 +112,7 @@ Recorded response content appends to the next request and does not invalidate it ## Known Limitations and Deferred Work - **Settings can add or override routes, not remove composition routes** — the user layer merges over the composition `base`, so deleting a `cordis.yml`-provided provider is a composition change; `replace` on the namespace only resets the user layer. -- **`apiKey` is schema-tagged `role('secret')` but not yet masked anywhere** — the settings `describe()` envelope returns values verbatim; the wire/UI layer that must redact secret-role fields ships with the settings RPC surface. +- **`headers` can carry a credential the redactor never sees** — the profile's `headers` dict is plain strings, so `Authorization` or `api-key` set there is returned verbatim by a redacted `describe()` and rendered by any configuration UI. Store credentials as `apiKeyEnv` references; making the dict write-only is deferred with the rest of the [wire-boundary work](../llm/README.md#known-limitations-and-deferred-work). - **Catalog membership is required** — custom model ids that are absent from the installed pi-ai catalog fail with `UNKNOWN_MODEL`, even when a provider profile supplies a custom endpoint. - **`GenerateOptions.stop` is unsupported** — pi-ai's common stream options cannot guarantee stop-sequence behavior across providers, so the adapter rejects the field. - **In-history `system` messages use pi-ai's common context conversion** — provider-specific placement follows pi-ai rather than a harness-owned wire override. diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index 3baed4e30b..5fb19ee134 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -112,7 +112,7 @@ pi-ai 事件会变为 harness 推理、文本、工具调用、usage 与 finish ## 已知限制与暂缓事项 - **settings 能新增或覆盖路由,但不能移除组合路由**:用户层合并在组合 `base` 之上,因此删除 `cordis.yml` 提供的提供方属于组合变更;对该 namespace 执行 `replace` 只会重置用户层。 -- **`apiKey` 已在 schema 中标注 `role('secret')`,但尚未在任何地方脱敏**:settings 的 `describe()` 信封原样返回值;负责对 secret 角色字段脱敏的 wire/UI 层将随 settings RPC 面一起交付。 +- **`headers` 可能承载一条脱敏器看不见的凭据**:profile 的 `headers` 是纯字符串字典,因此设在其中的 `Authorization` 或 `api-key` 会被脱敏后的 `describe()` 原样返回,并被任何配置 UI 渲染出来。请把凭据存为 `apiKeyEnv` 引用;把该字典整体改为只写与其余[协议边界工作](../llm/README.md#known-limitations-and-deferred-work)一并暂缓。 - **必须属于 catalog**:已安装 pi-ai catalog 中不存在的自定义模型 id 会以 `UNKNOWN_MODEL` 失败,即使提供方 profile 配置了自定义端点。 - **不支持 `GenerateOptions.stop`**:pi-ai 的通用流选项无法保证所有提供方都支持 stop sequence,因此适配器会拒绝该字段。 - **历史中的 `system` 消息使用 pi-ai 通用上下文转换**:提供方特定位置由 pi-ai 决定,而非由 harness 拥有的协议覆盖决定。 diff --git a/packages/settings/settings/README.i18n.yaml b/packages/settings/settings/README.i18n.yaml index 0a31ab6fd1..9f920486d6 100644 --- a/packages/settings/settings/README.i18n.yaml +++ b/packages/settings/settings/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/settings/settings/README.md -README.md: 293b56e53719bb35b2efb63af1f45dbc082b0738 -README.zh.md: 77807116253eaba7c48102dc4fc61a8a244f2931 +README.md: 1f1ce07722bfb035746ad5733f90ddabe2d1553b +README.zh.md: 0d96a0deda3b9d8f6260a1f223eb86cb87781565 diff --git a/packages/settings/settings/README.md b/packages/settings/settings/README.md index 293b56e537..1f1ce07722 100644 --- a/packages/settings/settings/README.md +++ b/packages/settings/settings/README.md @@ -10,7 +10,9 @@ Abstract user-settings seam (`ctx.settings`). One provider holds a raw document - `describe(options?)` — one descriptor per namespace (`schema.toJSON()` envelope, resolved value, detached `base`/`user` layers, `applies`) for configuration surfaces; a field's presence in `user` is what marks it user-overridden. `describe({ redactSecrets: true })` strips `role('secret')` fields from every layer and adds the `secrets` slot list (`{ path, set }`); every wire surface MUST pass it, and the pure `redactSecrets(schema, value)` walker is exported for other wires. - `get(ns)` — resolved value, `undefined` while unregistered. - `update(ns, patch)` — deep-merges the plain-object patch into the user section only (never the `base`), validates the resolved candidate, persists through the provider, then commits. Patches must be JSON-shaped data: a Date, Map, BigInt, non-finite number, or circular reference rejects with its `$`-rooted path before anything persists (YAML/JSON storage would silently distort such values on reload). Validation failure rejects before anything is persisted; a read-only provider (`writable: false`) rejects every write. Writes to one namespace are serialized in call order. -- `replace(ns, section)` — sets the user section wholesale: the removal/reset path a merge cannot express (`replace({})` re-inherits `base` and schema defaults). +- `replace(ns, section)` — sets the user section wholesale: the deliberate reset (`replace({})` re-inherits `base` and schema defaults). +- `mutate(ns, ops)` — applies ordered `{ op: 'set' | 'unset', path }` edits to the section as it stands when the write reaches the front of the queue. This is the removal path for any caller holding an INCOMPLETE view: a configuration UI reads the redacted descriptor, so rebuilding a section from it and replacing wholesale deletes every secret the wire never returned, while an op names the one field it means. +- Every write takes an optional `expectedRevision`. Each descriptor carries the namespace's `revision`, a monotonic counter over its RAW section; a write whose expectation no longer matches rejects with `SettingsConflictError` (`code: 'SETTINGS_CONFLICT'`, both revisions attached) instead of overwriting the writer that landed first. The write queue orders writes but cannot by itself tell a fresh writer from one holding a stale snapshot. - Resolved values are deep-frozen snapshots. Watchers receive `(next, prev)` after each commit: invocations of one callback run asynchronously, one at a time, in commit order (a slow stale invocation can never apply after a newer one), and failures — sync throws and async rejections alike — are contained. After a watch disposer returns, no further invocation starts (one already queued is skipped); an invocation already started still settles. The `settings/updated` event fans out one listener at a time, so one throwing listener cannot starve the rest; an async listener's rejection is contained and logged, which is why `INVARIANT`-coded failures rethrow only from synchronous listeners. - Service teardown refuses new writes and watcher starts, then drains every queued write and every started watcher invocation before disposal completes; a write whose registrant fiber was disposed mid-flight still reaches storage but commits and notifies nobody. @@ -20,7 +22,9 @@ Subclasses implement `writable`, `load()`, and `persist(ns, section)`, and push ## Events -`settings/updated (ns, next, prev, source)` fires after each commit; `source` is `update` (in-process write) or `provider` (external change). It never fires for a deep-equal resolved value. +`settings/updated (ns, next, prev, source)` fires after each commit; `source` is `update` (in-process write) or `provider` (external change). It never fires for a deep-equal resolved value — it is the consumer-facing event, and a consumer only cares that its value moved. + +`settings/document-updated (ns, revision)` fires whenever the RAW user section changes, whether or not the resolved value did. Configuration surfaces need this one: storing an override equal to the composition base leaves the resolved value alone but changes what the document says (the field is now overridden, not inherited) and moves the revision every open editor is holding. Listener containment matches `settings/updated`. ## Model Experience @@ -33,4 +37,5 @@ No direct invalidation; a consumer that folds a settings value into the request ## Known Limitations and Deferred Work - **Single user layer** — resolution knows schema defaults, one composition `base`, and one user document; there is no project/managed layering or per-value provenance yet. +- **`redactSecrets` is not a proven wire boundary** — the walker follows `object`/`dict`/`array`, so a `role('secret')` reached only through a union, intersection, or transform is returned VERBATIM with an empty `secrets` list, and `schema.toJSON()` carries a secret field's `.default(...)` to every client. Neither case is rejected; a schema whose secrets are not reachable through the walked containers must not be registered on a wire-exposed namespace. A fail-closed `describeForWire()` — one that refuses a schema it cannot prove safe, and sanitizes the serialized envelope and error text — is the real answer and is deferred. - **Cross-process concurrency is provider-defined** — the seam serializes writes per namespace in-process only; concurrent processes converge by provider behavior (the local file provider read-modify-writes under a writer lock, so namespaces survive concurrent writers and same-namespace conflicts resolve last-write-wins). diff --git a/packages/settings/settings/README.zh.md b/packages/settings/settings/README.zh.md index 7780711625..0d96a0deda 100644 --- a/packages/settings/settings/README.zh.md +++ b/packages/settings/settings/README.zh.md @@ -10,7 +10,9 @@ - `describe(options?)` — 每个 namespace 一条描述(`schema.toJSON()` 信封、解析值、分离出的 `base`/`user` 层、`applies`),供配置界面使用;字段出现在 `user` 中即标记其被用户覆盖。`describe({ redactSecrets: true })` 从每一层剥离 `role('secret')` 字段,并附加 `secrets` 槽位列表(`{ path, set }`);每个 wire 面都必须传入它,纯遍历器 `redactSecrets(schema, value)` 已导出,供其他 wire 使用。 - `get(ns)` — 解析值;未注册时为 `undefined`。 - `update(ns, patch)` — 把普通对象 patch 深合并进用户分节(绝不合并进 `base`),校验解析候选值,经 provider 持久化后提交。patch 必须是 JSON 形状的数据:Date、Map、BigInt、非有限数或循环引用会在任何内容持久化前带着以 `$` 为根的路径拒绝(YAML/JSON 存储在重载时会静默扭曲这类值)。校验失败在持久化前拒绝;只读 provider(`writable: false`)拒绝一切写入。同一 namespace 的写入按调用顺序串行。 -- `replace(ns, section)` — 整体替换用户分节:merge 表达不了的删除/重置路径(`replace({})` 重新继承 `base` 与 schema 默认值)。 +- `replace(ns, section)` — 整体替换用户分节:这是刻意的重置(`replace({})` 重新继承 `base` 与 schema 默认值)。 +- `mutate(ns, ops)` — 在写入排到队首那一刻的分节上,按序施加 `{ op: 'set' | 'unset', path }` 编辑。这是任何持有**不完整**视图的调用方的删除路径:配置 UI 读到的是脱敏后的 descriptor,据此重建分节再整体替换,会把 wire 从未回传的每个机密都删掉,而一条 op 只点名它真正要改的那个字段。 +- 每次写入都可携带可选的 `expectedRevision`。每个 descriptor 都带有该 namespace 的 `revision`——一个针对其**原始**分节的单调计数器;期望值不再匹配的写入会以 `SettingsConflictError`(`code: 'SETTINGS_CONFLICT'`,并附上两个 revision)被拒绝,而不是覆盖先落地的那个写方。写队列只保证写入的先后次序,它本身分辨不出一个新写方与一个持有过期快照的写方。 - 解析值是深冻结快照。每次提交后观察者收到 `(next, prev)`:同一回调的调用异步、逐次、按提交顺序执行(慢的旧调用绝不会覆盖更新的结果),异常——同步抛出与异步拒绝——均被隔离。watch 的 disposer 返回后不再启动新的调用(已排队的那一次会被跳过);已启动的调用仍会结算。`settings/updated` 事件逐 listener 扇出,一个抛错的 listener 不会饿死其余 listener;异步 listener 的拒绝会被隔离并记入日志,这正是 `INVARIANT` 编码的失败只从同步 listener 重新抛出的原因。 - 服务卸载先拒绝新写入与观察者调用的启动,再排干全部排队写入与已启动的观察者调用后才完成;registrant fiber 在写入途中被 dispose 时,该写入仍到达存储,但不向任何人提交或通知。 @@ -20,7 +22,9 @@ ## 事件 -`settings/updated (ns, next, prev, source)` 在每次提交后触发;`source` 为 `update`(进程内写入)或 `provider`(外部变更)。解析值深相等时绝不触发。 +`settings/updated (ns, next, prev, source)` 在每次提交后触发;`source` 为 `update`(进程内写入)或 `provider`(外部变更)。解析值深相等时绝不触发——它面向消费方,而消费方只关心自己的值有没有变。 + +`settings/document-updated (ns, revision)` 在**原始**用户分节发生变化时触发,无论解析值是否随之改变。配置界面需要的是这一个:存入一个与组合 `base` 相同的覆盖值不会改变解析值,却改变了文档的说法(该字段从继承变成了覆盖),也推进了每个已打开编辑器所持有的 revision。监听器的收容方式与 `settings/updated` 相同。 ## Model Experience @@ -33,4 +37,5 @@ ## Known Limitations and Deferred Work - **单一用户层** — 解析只认识 schema 默认值、一个组合 `base` 与一个用户文档;尚无 project/managed 分层或按值溯源。 +- **`redactSecrets` 并非一条可被证明的协议边界**:walker 只跟随 `object`/`dict`/`array`,因此只能经由 union、intersection 或 transform 抵达的 `role('secret')` 会被**原样**返回,且 `secrets` 列表为空;而 `schema.toJSON()` 会把 secret 字段的 `.default(...)` 一并带给每个客户端。这两种情况都不会被拒绝;机密无法经由被遍历的容器抵达的 schema,绝不可注册到暴露于协议的 namespace 上。真正的答案是一个 fail-closed 的 `describeForWire()`——它拒绝自己无法证明安全的 schema,并对序列化信封与错误文本做净化——此项暂缓。 - **跨进程并发由 provider 定义** — seam 仅在进程内按 namespace 串行化写入;跨进程并发按 provider 行为收敛(本地文件 provider 在写锁下读-改-写,因此 namespace 在并发写入者下不会丢失,同 namespace 冲突按后写胜出解决)。 diff --git a/packages/settings/settings/src/index.ts b/packages/settings/settings/src/index.ts index 2eaf32e9a8..75b23b9932 100644 --- a/packages/settings/settings/src/index.ts +++ b/packages/settings/settings/src/index.ts @@ -56,6 +56,11 @@ export interface SettingsDescriptor { schema: unknown /** Current resolved value. */ value: unknown + /** + * Monotonic revision of the raw user section this descriptor was read at. + * Send it back as `expectedRevision` on a write to refuse a stale one. + */ + revision: number /** Registrant's composition `base` layer (detached), when one was declared. */ base?: unknown /** @@ -130,6 +135,19 @@ declare module 'cordis' { * @mode emit */ 'settings/updated'(ns: SettingsNamespace, next: unknown, prev: unknown, source: SettingsUpdateSource): void + + /** + * One registered namespace's RAW user section changed, whether or not the + * resolved value did. `settings/updated` is the consumer-facing event and + * stays deep-equal-gated; this one exists for configuration surfaces, + * which must learn that a field went from inherited to overridden (same + * resolved value, different meaning) and that their held revision is + * stale. Listener containment matches `settings/updated`. + * @param ns - the namespace whose stored section changed. + * @param revision - the namespace's new revision. + * @mode emit + */ + 'settings/document-updated'(ns: SettingsNamespace, revision: number): void } } @@ -155,6 +173,32 @@ export function deepEqualJson(a: unknown, b: unknown): boolean { return keys.every(key => key in right && deepEqualJson(left[key], right[key])) } +/** + * A write refused because the namespace moved since the caller read it. The + * seam's serialized write queue orders writes; it cannot tell a fresh writer + * from one holding a stale snapshot, which is what this reports. + */ +export class SettingsConflictError extends Error { + /** Stable machine code for wire layers mapping this to their own taxonomy. */ + readonly code = 'SETTINGS_CONFLICT' + /** The revision the write expected. */ + readonly expected: number + /** The revision the namespace actually stands at. */ + readonly actual: number + + /** + * @param ns - the namespace whose write was refused. + * @param expected - the revision the caller sent. + * @param actual - the revision now stored. + */ + constructor(ns: SettingsNamespace, expected: number, actual: number) { + super(`settings namespace "${ns}" changed since it was read (expected revision ${String(expected)}, now ${String(actual)})`) + this.name = 'SettingsConflictError' + this.expected = expected + this.actual = actual + } +} + /** Whether a value is a plain data object (not an array, null, or class instance). */ function isPlainObject(value: unknown): value is Record { if (typeof value !== 'object' || value === null || Array.isArray(value)) return false @@ -300,6 +344,15 @@ interface SettingsRegistration { base: unknown applies: SettingsApplies resolved: unknown + /** + * Monotonic counter over this namespace's RAW user section — bumped by any + * change to what is stored, including one whose resolved value is + * unchanged (adding an override equal to the composition base). Editors + * carry it as `expectedRevision` to detect a concurrent write, and the + * document event carries it so another tab learns a field went from + * inherited to overridden. + */ + revision: number watchers: Set } @@ -383,6 +436,7 @@ export abstract class Settings extends Service { base: options?.base, applies: options?.applies ?? 'live', resolved: deepFreeze(this.resolve(schema, options?.base, this.section(ns))), + revision: 0, watchers: new Set(), } this.ctx.effect(() => { @@ -430,6 +484,7 @@ export abstract class Settings extends Service { ns: registration.ns, schema: registration.schema.toJSON(), value: registration.resolved, + revision: registration.revision, ...base === undefined ? {} : { base }, ...detachedUser === undefined ? {} : { user: detachedUser }, applies: registration.applies, @@ -464,9 +519,11 @@ export abstract class Settings extends Service { * merging over the previous write's committed section. * @param ns - the registered namespace to update. * @param patch - plain-object patch over the user section. + * @param expectedRevision - the descriptor `revision` the caller read; a + * namespace that moved past it rejects with {@link SettingsConflictError}. */ - async update(ns: SettingsNamespace, patch: object): Promise { - return this.write(ns, patch, 'merge') + async update(ns: SettingsNamespace, patch: object, expectedRevision?: number): Promise { + return this.write(ns, patch, 'merge', expectedRevision) } /** @@ -476,9 +533,11 @@ export abstract class Settings extends Service { * merge-only patch cannot express (`replace({})` re-inherits everything). * @param ns - the registered namespace to replace. * @param section - the complete next user section. + * @param expectedRevision - the descriptor `revision` the caller read; a + * namespace that moved past it rejects with {@link SettingsConflictError}. */ - async replace(ns: SettingsNamespace, section: object): Promise { - return this.write(ns, section, 'replace') + async replace(ns: SettingsNamespace, section: object, expectedRevision?: number): Promise { + return this.write(ns, section, 'replace', expectedRevision) } /** @@ -490,8 +549,10 @@ export abstract class Settings extends Service { * holding a redacted view; `replace` remains the wholesale reset. * @param ns - the registered namespace to edit. * @param ops - ordered path edits; later ops observe earlier ones. + * @param expectedRevision - the descriptor `revision` the caller read; a + * namespace that moved past it rejects with {@link SettingsConflictError}. */ - async mutate(ns: SettingsNamespace, ops: readonly SettingsPathOp[]): Promise { + async mutate(ns: SettingsNamespace, ops: readonly SettingsPathOp[], expectedRevision?: number): Promise { if (!Array.isArray(ops)) throw new TypeError(`settings mutate for "${ns}" must be an array of path ops`) for (const op of ops) { if (!isPlainObject(op) || (op['op'] !== 'set' && op['op'] !== 'unset')) { @@ -501,11 +562,16 @@ export abstract class Settings extends Service { throw new TypeError(`settings mutate for "${ns}" op paths must be arrays of strings`) } } - return this.write(ns, ops, 'mutate') + return this.write(ns, ops, 'mutate', expectedRevision) } /** Validate a write, then queue it on the namespace's serialized write chain. */ - private write(ns: SettingsNamespace, input: object, mode: 'merge' | 'replace' | 'mutate'): Promise { + private write( + ns: SettingsNamespace, + input: object, + mode: 'merge' | 'replace' | 'mutate', + expectedRevision?: number, + ): Promise { const verb = mode === 'merge' ? 'update' : mode === 'replace' ? 'replace' : 'mutate' const registration = this.registrations.get(ns) if (registration === undefined) { @@ -544,6 +610,12 @@ export abstract class Settings extends Service { // Every mode derives from the section as it stands NOW, at the front of // the queue — never from whatever the caller last saw. const current = this.section(ns) ?? {} + // The revision check belongs HERE, not at call time: the queue orders + // writes but cannot tell a fresh writer from one holding a snapshot + // that a predecessor already superseded. + if (expectedRevision !== undefined && expectedRevision !== registration.revision) { + throw new SettingsConflictError(ns, expectedRevision, registration.revision) + } const section = mode === 'merge' ? mergeLayers(current, snapshot) as Record : mode === 'replace' @@ -558,6 +630,7 @@ export abstract class Settings extends Service { // TODO(settings-replacement-resync): Re-resolve any replacement registration // from this persisted section so an old in-flight write cannot leave it stale. if (this.registrations.get(ns) === registration && !this.isStopped()) { + this.bumpRevision(registration, current, section) this.commit(registration, next, 'update') } }) @@ -573,6 +646,19 @@ export abstract class Settings extends Service { * @param source - change origin; defaults to `provider`. */ protected publish(doc: Record, source: SettingsUpdateSource = 'provider'): void { + // Read every raw section BEFORE swapping the document, so the revision + // bump below compares what was stored with what now is — an external edit + // moves the revision exactly like an in-process write. + const before = new Map() + for (const registration of this.registrations.values()) { + try { + before.set(registration.ns, this.section(registration.ns)) + } catch { + // A malformed stored section is not a readable "before"; treating it + // as absent still bumps against any well-formed replacement. + before.set(registration.ns, undefined) + } + } this.document = doc for (const registration of this.registrations.values()) { let next: unknown @@ -583,6 +669,7 @@ export abstract class Settings extends Service { this.ctx.logger.warn(error) continue } + this.bumpRevision(registration, before.get(registration.ns), this.section(registration.ns)) this.commit(registration, next, source) } } @@ -604,6 +691,42 @@ export abstract class Settings extends Service { return schema(mergeLayers(base, section) as never) } + /** + * Advance a namespace's revision when its RAW section changed, and announce + * it. Deliberately independent of {@link commit}'s resolved-value equality: + * storing an override equal to the composition base leaves the resolved + * value alone but changes what the document says, which is exactly what a + * configuration surface must re-read. + */ + private bumpRevision(registration: SettingsRegistration, before: unknown, after: unknown): void { + if (deepEqualJson(before, after)) return + registration.revision += 1 + this.emitDocumentUpdated(registration.ns, registration.revision) + } + + /** Contained fan-out of `settings/document-updated`, mirroring {@link commit}'s. */ + private emitDocumentUpdated(ns: SettingsNamespace, revision: number): void { + let invariantFailure: unknown + const args = ['settings/document-updated', ns, revision] + for (const listener of this.ctx.events.dispatch('emit', args) as Array<(...listenerArgs: unknown[]) => unknown>) { + try { + const returned = listener(ns, revision) + if (returned != null && typeof (returned as PromiseLike).then === 'function') { + void Promise.resolve(returned as PromiseLike).then(undefined, (error: unknown) => { + this.warnListenerFailure(ns, error) + }) + } + } catch (error) { + if ((error as { code?: unknown } | null)?.code === 'INVARIANT') { + invariantFailure ??= error + continue + } + this.warnListenerFailure(ns, error) + } + } + if (invariantFailure !== undefined) throw invariantFailure as Error + } + /** Commit a resolved value when changed: swap, notify watchers, emit the event. */ private commit(registration: SettingsRegistration, next: unknown, source: SettingsUpdateSource): void { const prev = registration.resolved diff --git a/packages/settings/settings/src/redact.ts b/packages/settings/settings/src/redact.ts index 68cb034e05..c9f4cda347 100644 --- a/packages/settings/settings/src/redact.ts +++ b/packages/settings/settings/src/redact.ts @@ -84,6 +84,9 @@ function walk(node: SchemaNode | undefined, value: unknown, path: string[], secr return value.map((entry, index) => walk(node.inner, entry, [...path, String(index)], secrets)) } default: + // TODO(settings-wire-redaction): Fail closed instead — a secret reachable + // only through a union, intersection, or transform is returned verbatim + // here, with nothing recording that it was missed. return value } } diff --git a/packages/settings/settings/tests/settings.spec.ts b/packages/settings/settings/tests/settings.spec.ts index 9fcf841551..7d52c9b5f9 100644 --- a/packages/settings/settings/tests/settings.spec.ts +++ b/packages/settings/settings/tests/settings.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import z from 'schemastery' -import { Settings, deepEqualJson, installSettingsSection, settingsNamespace, type SettingsNamespace, type SettingsScope, type SettingsUpdateSource } from '../src/index.ts' +import { Settings, SettingsConflictError, deepEqualJson, installSettingsSection, settingsNamespace, type SettingsNamespace, type SettingsScope, type SettingsUpdateSource } from '../src/index.ts' import { MemorySettings } from './memory.ts' /** A provider implementing only the three primitives: the seam owns init. */ @@ -811,3 +811,87 @@ describe('mutate (path-addressed writes)', () => { .rejects.toThrow(/must be JSON-shaped data/) }) }) + +describe('revision and conflict detection', () => { + const REV = settingsNamespace('rev') + const RevSchema: z<{ a: string; b: string }> = z.object({ + a: z.string().default('base-a'), + b: z.string(), + }) + + async function mounted(doc: Record = {}) { + const ctx = new Context() + await ctx.plugin(BareProvider, { doc }) + return ctx + } + + it('refuses a write whose expected revision is stale, leaving the winner in place', async () => { + // Two editors open the same namespace, both holding revision 0. The first + // to land wins; the second must be told rather than overwrite it. + const ctx = await mounted() + ctx.settings.register(REV, RevSchema) + const opened = ctx.settings.describe().find(d => d.ns === REV)!.revision + + await ctx.settings.update(REV, { b: 'from-tab-B' }, opened) + await expect(ctx.settings.update(REV, { a: 'from-tab-A' }, opened)) + .rejects.toThrow(/changed since it was read \(expected revision 0, now 1\)/) + expect(ctx.settings.describe().find(d => d.ns === REV)!.user).toEqual({ b: 'from-tab-B' }) + }) + + it('carries the machine code and both revisions on the refusal', async () => { + const ctx = await mounted() + ctx.settings.register(REV, RevSchema) + await ctx.settings.update(REV, { b: 'first' }) + const error = await ctx.settings.update(REV, { b: 'second' }, 0).catch((e: unknown) => e) + expect(error).toBeInstanceOf(SettingsConflictError) + expect(error).toMatchObject({ code: 'SETTINGS_CONFLICT', expected: 0, actual: 1 }) + }) + + it('accepts a write that carries no expectation at all', async () => { + const ctx = await mounted() + ctx.settings.register(REV, RevSchema) + await ctx.settings.update(REV, { b: 'one' }) + await ctx.settings.update(REV, { b: 'two' }) + expect(ctx.settings.describe().find(d => d.ns === REV)!.revision).toBe(2) + }) + + it('announces a raw change whose resolved value is unchanged', async () => { + // Storing an override equal to the schema default leaves `value` alone but + // changes what the document says: the field is now overridden, not + // inherited, and another tab has to learn that. + const ctx = await mounted() + ctx.settings.register(REV, RevSchema) + const documents: Array<[string, number]> = [] + const resolved: string[] = [] + ctx.on('settings/document-updated', (ns, revision) => { documents.push([String(ns), revision]) }) + ctx.on('settings/updated', (ns) => { resolved.push(String(ns)) }) + + await ctx.settings.update(REV, { a: 'base-a' }) + + expect(documents).toEqual([['rev', 1]]) + expect(resolved).toEqual([]) + expect(ctx.settings.describe().find(d => d.ns === REV)!.user).toEqual({ a: 'base-a' }) + }) + + it('does not move the revision when a write stores an identical section', async () => { + const ctx = await mounted({ rev: { b: 'same' } }) + ctx.settings.register(REV, RevSchema) + const documents: unknown[] = [] + ctx.on('settings/document-updated', (ns, revision) => { documents.push([String(ns), revision]) }) + await ctx.settings.update(REV, { b: 'same' }) + expect(documents).toEqual([]) + expect(ctx.settings.describe().find(d => d.ns === REV)!.revision).toBe(0) + }) + + it('moves the revision for an external edit the provider publishes', async () => { + const ctx = await mounted() + ctx.settings.register(REV, RevSchema) + const documents: Array<[string, number]> = [] + ctx.on('settings/document-updated', (ns, revision) => { documents.push([String(ns), revision]) }) + ;(ctx.settings as unknown as { publish(doc: Record): void }) + .publish({ rev: { b: 'edited on disk' } }) + expect(documents).toEqual([['rev', 1]]) + // An editor that opened before the external edit is now refused. + await expect(ctx.settings.update(REV, { b: 'stale' }, 0)).rejects.toThrow(SettingsConflictError) + }) +}) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 4f4ac87c1c..86368b03ed 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -192,6 +192,7 @@ export const LINK_MAP: Readonly> = { SettingsRegisterOptions: 'settings.md', SettingsScope: 'settings.md', SettingsDescriptor: 'settings.md', + SettingsPathOp: 'settings.md', SettingsDescribeOptions: 'settings.md', SettingsUpdateSource: 'settings.md', CredentialRef: 'credentials.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 5ac474f661..caf1465ce9 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1393,6 +1393,11 @@ "doc": "docs/core-data-structures/core.md", "symbol": "LlmConfigurableProvider", "source": "packages/llm/llm/src/types.ts" + }, + { + "doc": "docs/core-data-structures/settings.md", + "symbol": "SettingsPathOp", + "source": "packages/settings/settings/src/index.ts" } ] }