# Agent Note: 凭据边界、按整份快照发起的请求与原子路由注册

Status: implemented

[English](2026-07-30-credential-boundaries-and-atomic-registration.md) | 中文

> 范围：对[请求级 LLM（大语言模型）配置 seam](2026-07-29-request-level-llm-config-credentials.md)的第三轮评审——存下来的凭据落在哪里、谁能读到它，一次请求的事实如何保持为同一代，以及一组路由如何在不留空窗的前提下更换。本 note 与 [settings 写路径 note](2026-07-30-settings-write-path-integrity.md) 配套：本轮把那篇 note 的提供方修复套用到 `credentials-local`，并把其中的写锁提升进 `dsh-atomic-write`。

## 问题

评审发现，凭据路径正在越过它自己划下的边界泄漏。已交付的各个面在 Cordis 启动之前就把 `$DSH_HOME/.env` 提升进了 `process.env`，于是下一次运行时，`credentials-local` 会把它自己存下的每个键都判成来自环境的只读启动覆盖：`describe()` 报告 `source: 'env'` 且 `writable: false`，`set`/`unset` 以被遮蔽为由拒绝，从 web 页面或 TUI 存入的密钥既无法轮换也无法删除，而适配器还在继续使用启动时捕获的那个值。

存储自身的写路径重演了同一轮评审在 settings-local 修掉的那些缺陷（两条相互独立的链、从陈旧缓存渲染整份文件），还叠加了编辑器自己的缺陷：另一个键的带引号多行值内部的一条物理行会被读成赋值，CRLF 行尾会退化成 LF，多行条目报告 `writable: true` 而 `set` 总是抛错，`credentials/updated` 又在提交之后裸发，于是一个出错的观察者就能让一次已经落盘的写入看起来失败。

在读取一侧，文件的 `0600` 权限挡得住其他 OS 用户，却挡不住模型：它的 bash 与文件系统工具就以同一个用户身份运行。

与之并排的还有两个请求路径缺陷。DeepSeek 的按请求解析把连接事实保存在最后可用快照里，却仍从原始配置重新读取字面 `apiKey`，于是被 resolver 拒绝的那一代设置，照样能把自己的密钥送到上一代的端点上。配置了 `apiKeyEnv` 却解析不到值时，pi-ai 会把 `undefined` 交给 SDK，让 pi-ai 自己的环境发现拿一个毫不相干的提供方密钥完成鉴权——那是另一个租户，账单还悄悄记在它头上。而且它的路由替换是先释放旧注册、再创建新注册：只要有一条路由已被别的适配器占有，现有路由就会被全部丢掉，此后事实缓存可能与注册表中的事实相等，于是把配置改回可用状态也不会重新生效。

## 决策

**`$DSH_HOME/.env` 只归凭据提供方所有。**没有任何一个面会把它加载进 `process.env`。真正的启动环境，以及调用目录中由 bin 加载的 `.env`，仍然是那一层只读的环境来源，因此不挂载该提供方的组合，解析密钥的方式与从前完全一致，而存下的密钥跨重启仍然来源于文件、仍然可写——这一点由 Loader 组合中的一次真实重启来证明，而不是靠对 `describe()` 的单元断言。

**存下的凭据对模型没有边界，而 README 就是这么写的。**`0700` 目录下的 `0600` 挡得住其他 OS 用户；模型的 bash 与文件系统工具正是以同一用户身份运行，而已交付的默认值不约束任何东西。harness 真正守住的更窄，也就照这个宽度写下来：没有任何一个面会把该文档提升进 `process.env`，模型也从不会拿到它的解析后路径，因此要拿到这个值，需要刻意去读一条并未交给它的路径。OS 钥匙串（keychain）提供方——一个模型的进程根本读不到的存储——被记录为真正的答案，而不是靠一个残缺的方案去暗示它。

**一次请求，一代设置。**DeepSeek 解析出的快照在端点旁一并携带凭据事实（字面密钥与引用），`resolveApiKey` 接收这份快照，而不再重新读取配置。被拒绝的那一代如今完全不再贡献任何东西。只有当一个 profile 完全没有点名凭据时，pi-ai 才交给提供方原生的发现流程；配置了引用却解析不到，就以 `MISSING_CREDENTIAL` 失败，并点名该路由与该引用。启动时的凭据探测被删除：它可能在凭据服务挂载之前就运行，并把每一种失败都报成密钥缺失，而第一次请求本就会给出准确的错误。

**路由替换是注册表的操作，不是调用方的一串步骤。**`registerAdapter` 返回一个携带 `replace(providers)` 的句柄：候选集合先被完整校验（冲突、名称、提供方元数据），再在一个同步区段内完成替换。被拒绝的替换会让先前的路由保持注册并继续服务，而调用方的事实缓存只有在注册表确实持有新集合之后才会推进，因此改回可用配置时会重新生效。pi-ai 的注册事实按提供方排序，因此仅仅调换键顺序的设置文档不再算作路由变更。

**已提交的凭据写入采用收容式发布。**`Credentials.notifyUpdated` 逐个监听器扇出 `credentials/updated`；同步抛错与异步 rejection 都只记日志，不改变已提交操作的结果，而带 `INVARIANT` 代码的失败会在每个监听器都运行完之后重抛——与 settings seam 处理 `settings/updated` 的形状相同。`installSettingsSection` 的清理现在会区分它的两个触发来源：提供方脱离时仍回退到组合的 entry 配置并重新推导，而消费方自身卸载时立即返回，不再在拆卸过程中重新注册路由。

## 曾考虑的替代方案

- **用沙箱点名拒读 `$DSH_HOME/.env`**——已按 `readDenyPaths` 策略字段实现过（末尾一条 SBPL `deny file-read* file-write*`、一条 `/dev/null` 的 bwrap bind），又被它自己的证据推翻。bwrap 必须在自己 profile 已经置为只读的目录树内部创建该 bind 的挂载点，因此只要父目录不存在，它就会拒绝整次约束——那是每一台还没有存过凭据的主机，包括全新安装；Landlock 无法从它自己对 `/` 的读取授权中减去任何东西，于是每一次受限调用都会为一个它其实从未藏起的文件报 `partial`。一项在生效之处破坏约束、在不生效之处误报的保护，比一条写明的「没有保护」更糟。至于拒掉整个 harness home，早先另有理由被否：它同时覆盖 `sessions/`，而 `DSH_SESSION_JSONL` 是一项成文的、模型可见的能力。
- **把 `DSH_HOME` 从模型的 bash 环境中移除**——作为纵深防御考虑过，最终按「有真实代价的表演」不予采纳：默认 home 是 agent（智能体）能自行重建的成文约定，而这个变量正是正当工具链定位 harness 状态的途径。这里并不存在一条需要它来补强的边界，藏起指针只会让这份缺席更难被看见。
- **本轮就交付 OS 钥匙串提供方**——只有这个设计能让模型的进程真正读不到机密，而它是一个带三种平台后端的兄弟包。把它与本轮评审的其余工作放在一起评估体量，会拖慢其他每一项修复；它被记录为那个延后的答案，而不是一个「也许」。
- **做成 `replaceRegistration(previous, next)` 服务方法**——这是评审给出的形状，但它要求调用方自行携带上一个句柄，也允许它传入一个不匹配的句柄。把 `replace` 挂在注册句柄上，让归属关系变成结构性的：只有持有路由的那一项注册才能替换它们。

## 后果

`update()` 邻近的行为多了成文的失败模式：凭据写入现在可能因锁截止时间到期、或磁盘文档无法解析而失败，`describe()` 对它不会改写的多行条目报告 `writable: false`。`LlmAdapter` 的注册方无需改动即可继续工作（句柄本身仍可当作释放器调用），`DeepSeekConnectionOptions` 则新增了凭据字段，因此以编程方式构造该适配器必须提供 `apiKeyEnv`。延后事项：OS 钥匙串凭据提供方，以及针对两个写方编辑同一引用的逐值修订号检查（后写胜出仍是成文的解决方式）。
