Splitting the credential store from the user environment layer
把凭据存储与用户环境层拆开
`$DSH_HOME/.env` carried two incompatible jobs. It was the writable secret store of [`credentials-local`](../../../../packages/credentials/credentials-local/README.md), so no surface could hoist it into `process.env` — hoisting would make every stored key read as a read-only launch override and block rotation from the Models page. But its name and dotenv format promise an environment file, so users put non-secrets in
English
Problem
$DSH_HOME/.env carried two incompatible jobs. It was the writable secret store of credentials-local, so no surface could hoist it into process.env — hoisting would make every stored key read as a read-only launch override and block rotation from the Models page. But its name and dotenv format promise an environment file, so users put non-secrets in it and those values reached nothing: a DEEPSEEK_BASE_URL beside a working DEEPSEEK_API_KEY in the same file was silently ignored, because only the credential provider read the document and it addresses credential references alone.
One file cannot be both a store the Harness owns and isolates and a layer that propagates by ordinary environment rules. The request-level credential decision chose dotenv to match peer products' home .env, and the conflation was not visible until a non-secret needed the same file.
Decision
The two jobs become two files under the Harness home.
.credentials.yaml is the provider-managed store. A strict YAML mapping of CredentialRef to non-empty string, with no version field and no wrapper level:
DEEPSEEK_API_KEY: sk-…
OPENAI_API_KEY: sk-…
Because the document holds credentials and nothing else, every deviation is a rejection rather than a skipped entry: a non-mapping root, a key that is not a POSIX identifier, a non-string value, an empty string, a duplicate key, and malformed YAML all fail — loud at boot and at a write, warn-and-keep-the-last-good-snapshot on a live reload. A silently ignored key would read as "the secret I stored has no effect", which is the failure this change exists to remove. The dotenv physical-line editor is replaced by a patch of the parsed document, so comments and untouched entries keep their formatting, any string value round-trips (multi-line included), and no entry is unwritable for want of a quoting style. The writer lock, read-modify-write, atomic 0600 write under a 0700 directory, exact-path watcher, content-equality self-write suppression, and quiescent disposal are unchanged.
$DSH_HOME/.env is the user's ordinary environment layer. loadLayeredEnv in dsh-app-boot parses the invoking directory's .env and then the Harness home's, giving user < project < inherited by materializing each accepted value only when the process has no higher-layer value. The Harness home is resolved from the inherited environment before either file loads, so a project .env cannot redirect which user document is read. Only the product CLI layers these files; SDK and example bins keep loading their own directory through loadEnv and must not inherit a developer's $DSH_HOME.
Credential precedence distinguishes the inherited environment from discovered files: the inherited value stays the read-only per-run override, the managed document wins next, and project then user .env values remain writable fallbacks. A set therefore replaces a discovered-file value instead of rejecting a write that only the flattened process.env view would consider shadowed.
There is no migration. A key already in $DSH_HOME/.env keeps resolving as a fallback, while the managed document wins as soon as the Models page stores that reference.
Consequences
- Given up: a key left in
$DSH_HOME/.envis materialized intoprocess.env, so it reaches subprocesses under the subprocess credential scrub rather than staying inside the provider. It remains a writable fallback below.credentials.yaml; a secret the Harness should own and isolate belongs in the managed document, which is never materialized. - Bought: a non-secret in the user's
.envfinally takes effect, which was the original defect; the document format can reject what it cannot serve; and0600covers a file that holds only secrets instead of a file users are told to put ordinary configuration in. - The
0600the provider writes is also enforced on what it reads: on POSIX, a document with any group or other permission bit fails the launch before its contents are read, at boot and on every reload, and the diagnostic names thechmod 600repair. Windows has no mode to inspect — its ACLs are not expressible here — so the check is skipped rather than faked. - The
0600boundary still stops other OS users and not the model, unchanged by this split — the provider README owns that limit and the keychain-provider deferral.
Alternatives considered
Keep one $DSH_HOME/.env and teach the CLI to hoist it. Rejected: hoisting the store is precisely what makes stored keys unrotatable, which is why app-boot documented the exclusion in the first place. The conflict is the file's two jobs, not the loader.
$DSH_HOME/.credentials.env — a second dotenv file. Rejected: dotenv suits an environment layer but cannot express "a managed document indexed by credential reference". It cannot reject a non-string or an unaddressable key, and its line editor already refused values it could not quote, leaving entries readable but unwritable.
Add a version field to the new document. Rejected: the format is one schema-constrained string mapping with no historical variant to discriminate. While the product is unreleased, changing the structure and rejecting the old one beats promising a migration protocol.
Migrate credential-shaped keys out of $DSH_HOME/.env on first run. Rejected: migration code turns a short-lived format into a long-lived maintenance surface, and classifying which keys in an unknown file are secrets is exactly the ambiguity this split removes. The old file keeps working as environment, which is a truthful outcome rather than a silent one.
Drop the user .env layer entirely and keep only the inherited environment. Rejected here as out of scope: it is a coherent design (fewer layers, one place per value), but it removes a workflow users have, and the layering question belongs with the deferred precedence decision rather than with this split.
中文
Problem
$DSH_HOME/.env 同时承担了两件互不相容的工作。它是 credentials-local 的可写密钥存储,因此任何表层都不能把它提升进 process.env——一旦提升,每个已存密钥都会读作只读的启动时覆盖,从而阻断从 Models 页轮换密钥。但它的文件名和 dotenv 格式承诺的是一个环境文件,于是用户把非机密值放进去,而那些值哪儿也到不了:同一个文件里,一个能用的 DEEPSEEK_API_KEY 旁边的 DEEPSEEK_BASE_URL 会被静默忽略,因为只有凭据 provider 读这份文档,而它只寻址凭据引用。
一个文件无法既是由 Harness 拥有并隔离的存储,又是按普通环境规则传播的层。请求级凭据决策当初选择 dotenv 是为了对齐同类产品的 home .env,而这种混同直到有非机密值需要用同一个文件时才暴露出来。
Decision
两件工作在 Harness home 下拆成两个文件。
.credentials.yaml 是 provider 管理的存储。 一份从 CredentialRef 到非空字符串的严格 YAML 映射,没有 version 字段,也没有包装层:
DEEPSEEK_API_KEY: sk-…
OPENAI_API_KEY: sk-…
因为该文档只存放凭据、别无他物,任何偏离都是拒绝而不是跳过条目:根节点不是映射、非 POSIX 标识符的键、非字符串值、空字符串、重复键以及格式错误的 YAML 全部失败——启动时和写入时响亮失败,运行期热重载则告警并保留最后可用快照。被静默忽略的键读起来就是「我存进去的密钥没有生效」,而这正是本次变更要消除的失败。dotenv 物理行编辑器被替换为对已解析文档打补丁,因此注释与未触及条目的排版都会保留,任何字符串值都能往返(含多行),也不会再有条目因为缺少可用引号样式而不可写。写锁、read-modify-write、0700 目录下的 0600 原子写、精确路径 watcher、按内容相等抑制自写、以及 dispose 时的完全停稳,均保持不变。
$DSH_HOME/.env 是用户的普通环境层。 dsh-app-boot 中的 loadLayeredEnv 先解析调用目录的 .env,再解析 Harness home 的,并且只在进程中没有更高层值时物化每个已接受的值,从而得到 用户 < 项目 < 继承。Harness home 在两个文件加载之前就从继承的环境解析完毕,因此项目 .env 无法改变读取哪份用户文档。只有产品 CLI(命令行界面)叠加这两个文件;SDK 与示例 bin 仍通过 loadEnv 加载各自的目录,绝不继承开发者的 $DSH_HOME。
凭据优先级会区分继承环境与发现的文件:继承值仍是只读的按次覆盖,其后是受管文档,再后是仍可写的项目与用户 .env 后备值。因此 set 会替换发现文件中的值,而不是因为扁平化的 process.env 视图认为写入会被遮蔽就加以拒绝。
不做迁移。已经放在 $DSH_HOME/.env 里的密钥会继续作为后备值解析;Models 页一旦存储该引用,受管文档就会优先。
Consequences
- 放弃的:留在
$DSH_HOME/.env里的密钥会被物化进process.env,因而会按子进程凭据清洗的规则抵达子进程,而不再留在 provider 内部。它仍是.credentials.yaml之下的可写后备值;需要由 Harness 拥有并隔离的密钥属于受管文档,后者永不物化。 - 换来的:用户
.env里的非机密值终于生效,这正是最初的缺陷;文档格式可以拒绝它无法承担的内容;0600保护的是一个只存密钥的文件,而不是一个我们同时叫用户往里写普通配置的文件。 - 提供方写入时使用的
0600同样约束它读取的内容:在 POSIX 上,只要文档带有任何 group 或 other 权限位,就会在读取内容之前让启动失败——启动时与每次 reload 都检查,诊断里给出chmod 600的修复命令。Windows 没有可检查的 mode(其 ACL 无法在此表达),因此跳过该检查而不是伪造它。 0600这条边界仍然只挡其他 OS 用户、挡不住模型,本次拆分未改变这一点——该限制及 keychain 提供方的延后项归 提供方 README 所有。
Alternatives considered
保留单一的 $DSH_HOME/.env,让 CLI 去提升它。 否决:提升存储本身正是让已存密钥无法轮换的原因,这也是 app-boot 当初记录该排除的理由。冲突来自这个文件的两份工作,而不是加载器。
$DSH_HOME/.credentials.env——第二个 dotenv 文件。 否决:dotenv 适合环境层,却无法表达「一份按凭据引用索引的受管文档」。它无法拒绝非字符串或无法寻址的键,而且它的行编辑器本来就会拒绝无法加引号的值,留下可读却不可写的条目。
给新文档加 version 字段。 否决:该格式只有一个受 schema 约束的字符串 mapping,没有需要判别的历史变体。在未发布阶段,直接修改结构并拒绝旧结构,好过提前承诺迁移协议。
首次运行时把形似凭据的键从 $DSH_HOME/.env 迁出。 否决:迁移代码会把短命格式变成长期维护面,而判断一个未知文件里哪些键是密钥,恰恰是本次拆分要消除的歧义。旧文件继续作为环境工作,这是诚实的结果,而不是静默的结果。
彻底取消用户 .env 层,只保留继承的环境。 在此处否决为超出范围:它本身是自洽的设计(层次更少、每个值只有一处来源),但会移除用户已有的工作流,而分层问题属于那个被延后的优先级决策,不属于本次拆分。