credential boundaries, whole-snapshot requests, and atomic route registration
凭据边界、按整份快照发起的请求与原子路由注册
The credential path leaked across boundaries it had drawn. The shipped surfaces hoisted `$DSH_HOME/.env` into `process.env` before cordis booted, so on the next run `credentials-local` classified every key it had stored itself as a read-only ambient launch override: `describe()` reported `source: 'env'` with `writable: false`, `set`/`unset` rejected as shadowed, and a key stored from the web page or TUI became unrota
English
Problem
The credential path leaked across boundaries it had drawn. The shipped surfaces hoisted $DSH_HOME/.env into process.env before cordis booted, so on the next run credentials-local classified every key it had stored itself as a read-only ambient launch override: describe() reported source: 'env' with writable: false, set/unset rejected as shadowed, and a key stored from the web page or TUI became unrotatable and undeletable while the adapter kept using the value captured at launch. The store's own write path repeated the settings-file defects the settings write-path note fixes (two independent chains, whole-file render from a stale cache), plus editor bugs of its own: a physical line inside another key's quoted multi-line value read as an assignment, CRLF endings degraded to LF, a multi-line entry reported writable: true while set always threw, and credentials/reference-updated was emitted bare after the commit, so one broken observer made a durable write look failed. On the read side, the file's 0600 mode stops other OS users but not the model, whose bash and filesystem tools run as the same user.
Two request-path defects sat beside them. DeepSeek resolved connection and credential facts independently, so a settings generation the resolver rejected could still pair its credential choice with the previous generation's endpoint. pi-ai handed the SDK undefined when a configured apiKeyEnv resolved to nothing, letting pi-ai's own environment discovery authenticate with an unrelated provider key — another tenant, silently billed. And its route swap disposed the old registration before creating the new one: a route another adapter owned dropped every existing route, after which the facts cache could equal the registry's, so restoring the working configuration never re-applied.
Decision
The credential document belongs to the credential provider alone. No surface loads it into process.env. It was $DSH_HOME/.env here; the credentials document split later moved it to $DSH_HOME/.credentials.yaml, so today it is the old path that is loaded — as the user's ordinary environment layer, holding no provider-managed secret. The genuine launch environment and the invoking directory's .env (loaded by the bin) stay the read-only ambient layer, so a composition without the provider resolves keys exactly as before, while a stored key stays file-sourced and writable across restarts — proven by a real restart in the loader composition rather than by a unit assertion about describe().
The stored credential has no boundary against the model, and the READMEs say so. 0600 under a 0700 directory stops other OS users; the model's bash and filesystem tools run as that same user, and the shipped default confines nothing. What the harness does hold to is narrower and stated as exactly that: no surface hoists the document into process.env, and the model is never handed a resolved path to it, so reaching the value takes a deliberate read of a path it was not given. An OS-keychain provider — a store the model's processes cannot read at all — is recorded as the real answer rather than implied by a partial one.
One request, one generation. DeepSeek's resolved snapshot carries the credential reference beside the endpoint, and resolveApiKey receives that snapshot instead of re-reading configuration. A rejected generation now contributes nothing at all. pi-ai defers to provider-native discovery only for a profile naming no credential; a configured reference that misses fails with MISSING_CREDENTIAL naming the route and the reference. The boot-time credential probe is deleted: it could run before the credentials service mounted and reported every failure as a missing key, while the first request already gives the accurate error.
Route replacement is a registry operation, not a caller sequence. registerAdapter returns a handle carrying replace(providers): the candidate set is validated in full first (conflicts, names, provider metadata), then swapped in one synchronous section. A refused replacement leaves the previous routes registered and serving, and the caller's facts cache only advances after the registry actually holds the new set, so reverting to a working configuration re-applies. pi-ai's registration facts are sorted by provider, so a settings document that merely reorders its keys is no longer a route change.
Contained publication for committed credential writes. CredentialProvider.notifyUpdated fans credentials/reference-updated out one listener at a time; sync throws and async rejections are logged without changing the committed operation's outcome, and INVARIANT-coded failures rethrow after every listener ran — the same shape the settings seam uses for settings/updated. installSettingsSection's cleanup now distinguishes its two triggers: a provider detaching still falls back to the composition entry and re-derives, while the consumer's own unload returns immediately instead of re-registering routes during teardown.
Alternatives considered
- A sandbox read-denial naming
$DSH_HOME/.env— implemented as areadDenyPathspolicy field (a trailing SBPLdeny file-read* file-write*, a/dev/nullbwrap bind) and withdrawn on its own evidence. bwrap must create that bind's mount point inside a tree its profile has already made read-only, so it refuses the entire confinement whenever the parent directory is absent — every host that has not stored a credential yet, including a fresh install; Landlock cannot subtract from its own/read grant, so every confined call would reportpartialfor a file it never hid. A protection that breaks confinement where it works and misreports it where it does not is worse than a documented absence. Denying the whole harness home was rejected earlier for a separate reason: it also coverssessions/, andDSH_SESSION_JSONLis a documented model-visible capability. - Removing
DSH_HOMEfrom the model's bash environment — considered as defense in depth and rejected as theater with a real cost: the default home is a documented convention the agent can reconstruct, while the variable is how legitimate tooling finds harness state. There is no boundary here for it to complement; hiding the pointer would only make the absence harder to see. - Shipping the OS-keychain provider now — it is the only design where the model's processes genuinely cannot read the secret, and it is a sibling package with three platform backends. Sizing it against the rest of these fixes would have delayed every other one; it is recorded as the deferred answer, not as a maybe.
- A
replaceRegistration(previous, next)service method — rejected: it makes the caller carry the previous handle and lets it pass a mismatched one. Hangingreplaceon the registration handle makes ownership structural: only the registration that holds routes can replace them.
Consequences
update()-adjacent behavior gained documented failure modes: a credential write can now fail on the lock deadline or on an unparsable on-disk document, and describe() reports writable: false for multi-line entries it will not rewrite. LlmAdapter registrants keep working unchanged (the handle is still callable as the disposer), and DeepSeekConnectionOptions gained credential fields, so a programmatic constructor of the adapter must supply apiKeyEnv. Deferred: the OS-keychain credential provider, and per-value revision checks for two writers editing one reference (last-write-wins remains the documented resolution).
中文
问题
凭据路径越过它自己划下的边界发生了泄漏。已交付的各个面在 Cordis 启动之前就把 $DSH_HOME/.env 提升进了 process.env,于是下一次运行时,credentials-local 会把它自己存下的每个键都判成来自环境的只读启动覆盖:describe() 报告 source: 'env' 且 writable: false,set/unset 以被遮蔽为由拒绝,从 web 页面或 TUI 存入的密钥既无法轮换也无法删除,而适配器还在继续使用启动时捕获的那个值。
存储自身的写路径重演了 settings 写路径 note 在 settings-file 修掉的那些缺陷(两条相互独立的链、从陈旧缓存渲染整份文件),还叠加了编辑器自己的缺陷:另一个键的带引号多行值内部的一条物理行会被读成赋值,CRLF 行尾会退化成 LF,多行条目报告 writable: true 而 set 总是抛错,credentials/reference-updated 又在提交之后裸发,于是一个出错的观察者就能让一次已经落盘的写入看起来失败。
在读取一侧,文件的 0600 权限挡得住其他 OS 用户,却挡不住模型:它的 bash 与文件系统工具就以同一个用户身份运行。
与之并排的还有两个请求路径缺陷。DeepSeek 分别解析连接事实与凭据事实,因此被 resolver 拒绝的那一代设置仍可能把自己的凭据选择与上一代的端点配在一起。配置了 apiKeyEnv 却解析不到值时,pi-ai 会把 undefined 交给 SDK,让 pi-ai 自己的环境发现拿一个毫不相干的提供方密钥完成鉴权——那是另一个租户,账单还悄悄记在它头上。而且它的路由替换是先 dispose(资源释放)旧注册、再创建新注册:只要有一条路由已被别的适配器占有,现有路由就会被全部丢掉,此后事实缓存可能与注册表中的事实相等,于是把配置改回可用状态也不会重新生效。
决策
**凭据文档只归凭据提供方所有。**没有任何一个面会把它加载进 process.env。当时该文档是 $DSH_HOME/.env;凭据文档拆分后来把它移到 $DSH_HOME/.credentials.yaml,因此如今被加载的正是那条旧路径——作为用户的普通环境层,其中不含任何提供方管理的密钥。真正的启动环境,以及调用目录中由 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 的注册事实按提供方排序,因此仅仅调换键顺序的设置文档不再算作路由变更。
已提交的凭据写入采用收容式发布。CredentialProvider.notifyUpdated 逐个监听器扇出 credentials/reference-updated;同步抛错与异步 rejection 都只记日志,不改变已提交操作的结果,而带 INVARIANT 代码的失败会在每个监听器都运行完之后重抛——与 settings seam 处理 settings/updated 的形状相同。installSettingsSection 的清理现在会区分它的两个触发来源:提供方脱离时仍回退到组合的 entry 配置并重新推导,而消费方自身卸载时立即返回,不再在拆卸过程中重新注册路由。
曾考虑的替代方案
- 用沙箱点名拒读
$DSH_HOME/.env——已按readDenyPaths策略字段实现过(末尾一条 SBPLdeny 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 钥匙串凭据提供方,以及针对两个写方编辑同一引用的逐值修订号检查(后写胜出仍是成文的解决方式)。