Files
deepseek-harness/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.zh.md

7.4 KiB
Raw Blame History

Agent Note: 配置面暴露什么,以及谁有权覆盖什么

Status: implemented

English | 中文

范围:对 Web 配置面的边界加固——哪些 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.describecredentials.describe 加入仅限回环的集合,因此在真正的认证层出现之前,整个配置面都保持同源。模型目录(llm.providersllm.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 已经过期。该事件被原样转发,模型消费方同时订阅它与 llm/adapters-updated,因为提供方设置持有不会由路由变化宣告的目录数据。

曾考虑的替代方案

  • 在代理配置上做部署声明式的 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 协议。