feat(settings): detect stale writers with a revision, and announce raw changes

The remaining P1 from the #939 review, plus the P2 it shares a mechanism with.

Nothing carried a version, so two tabs editing one namespace silently
overwrote each other — reproduced as tab B's `reasoning` lost to tab A's
older draft. The seam's per-namespace write queue orders writes; it cannot
tell a fresh writer from one replaying a snapshot a predecessor superseded.

Each namespace now carries a monotonic `revision` over its RAW section. A
write may send `expectedRevision`, checked at the FRONT of the queue (not at
call time, which would race the very predecessor it guards against); a
mismatch rejects with `SettingsConflictError` → `settings-conflict` on the
wire, carrying both revisions. The editor captures the revision it opened at
and, on conflict, asks the user to reopen rather than replaying its snapshot.

The same counter fixes the missing broadcast. `settings/updated` is gated on
the resolved value — correct for consumers, wrong for configuration surfaces:
storing an override equal to the composition base leaves the resolved value
alone while changing what the document says (the field is now overridden, not
inherited) and moving every open editor's revision. `settings/document-updated
(ns, revision)` fires on any raw-section change, in-process or external, and
`host/settings-changed` now rides it.

That event also closes the stale model picker: editing a provider's `models`
changes no route, so `llm/adapters-updated` never fired and an open picker
kept serving the old catalog. A change to an exposed provider namespace now
emits `host/models-changed` too — that namespace holds the catalog.

Docs: both sides of the five touched README pairs, a type-equiv block for
`SettingsPathOp`, and an Agent Note recording what the plane exposes and who
may overwrite what. The deferred wire-redaction gaps (secrets behind
union/intersection/transform, `.default(...)` in the served envelope, schema
text in rejection messages, `new Function` rehydration, pi-ai's `headers`) are
recorded as TODO(settings-wire-redaction) and in Known Limitations rather than
half-fixed.
This commit is contained in:
Yichen Jiang
2026-07-30 19:24:21 +08:00
parent 9f996be8e3
commit e6483f0afc
49 changed files with 598 additions and 78 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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` + 根路径)

View File

@@ -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<RpcResponse<SettingsNamespaceView>> {
const settings = ctx.get('settings')
if (settings === undefined) return err(request, settingsAbsent())
const rejected = (error: unknown): RpcResponse<SettingsNamespaceView> => err(request, {
code: 'settings-rejected',
message: error instanceof Error ? error.message : String(error),
details: { ns },
})
const rejected = (error: unknown): RpcResponse<SettingsNamespaceView> => {
// 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) }))

View File

@@ -52,6 +52,7 @@ export const rpcErrorSchema: z.ZodType<RpcError> = 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({}) }),

View File

@@ -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 }

View File

@@ -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<Wire<SettingsNamespaceView>>
/** 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<Wire<RequestPayload<'settings.update'>>>
/** 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<Wire<RequestPayload<'settings.replace'>>>
/** 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<Wire<RequestPayload<'settings.mutate'>>>
/** settings.mutate response value: the namespace's new redacted view. */

View File

@@ -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<RpcResponse<SettingsNamespaceView>>
update(request: RpcRequest<{ ns: string; patch: object; expectedRevision?: number }>): Promise<RpcResponse<SettingsNamespaceView>>
/**
* 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<RpcResponse<SettingsNamespaceView>>
replace(request: RpcRequest<{ ns: string; section: object; expectedRevision?: number }>): Promise<RpcResponse<SettingsNamespaceView>>
/**
* 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<RpcResponse<SettingsNamespaceView>>
mutate(
request: RpcRequest<{ ns: string; ops: SettingsPathOpView[]; expectedRevision?: number }>,
): Promise<RpcResponse<SettingsNamespaceView>>
}

View File

@@ -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' } })

View File

@@ -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',