feat(credentials): move the store to .credentials.yaml and layer $DSH_HOME/.env
$DSH_HOME/.env carried two incompatible jobs. As credentials-local's writable secret store it could not be hoisted into process.env — hoisting makes every stored key read as a read-only launch override and blocks rotation from the TUI and the web page. But its name and dotenv format promise an environment file, so a DEEPSEEK_BASE_URL sitting beside a working DEEPSEEK_API_KEY in the same file was silently ignored: only the credential provider read the document, and it addresses credential references alone. Split the two jobs into two files. .credentials.yaml is the provider-managed store: a strict YAML mapping of CredentialRef to non-empty string, no version field, no wrapper level. Because it holds credentials and nothing else, a non-mapping root, a non-identifier key, a non-string value, an empty string, a duplicate key, and malformed YAML are all rejections rather than skipped entries — loud at boot and at a write, warn-and-keep-last-good on a live reload. The dotenv physical-line editor gives way to a patch of the parsed document, so comments and untouched entries keep their formatting and any string value round-trips, multi-line included. Writer lock, read-modify-write, atomic 0600 write under a 0700 directory, watcher, self-write suppression, and quiescent disposal are unchanged. $DSH_HOME/.env becomes the user's ordinary environment layer. app-boot's new loadLayeredEnv loads the invoking directory's .env then the Harness home's, giving user < project < inherited; the home resolves from the inherited environment first, so a project .env cannot redirect it. Credential precedence is unchanged: the live environment still wins read-only over the file, and shadowed writes still reject. Whether a provider-managed store should instead win over the environment is a separate decision. No migration: a key already in $DSH_HOME/.env keeps resolving through the new environment layer, as a read-only env source that shadows the stored one.
This commit is contained in:
@@ -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 .agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md
|
||||
2026-07-29-request-level-llm-config-credentials.md: f12a2496a767decc3ce2b065f6be03009aec8992
|
||||
2026-07-29-request-level-llm-config-credentials.zh.md: 99fd90013a24746962ca02a5f4f18cdccd53f71a
|
||||
2026-07-29-request-level-llm-config-credentials.md: 5359865d1ca0c6620f4af1fa82c2f7e5413e79d6
|
||||
2026-07-29-request-level-llm-config-credentials.zh.md: e23bf92a0d8efa68ad682e002f07732aaa114049
|
||||
|
||||
@@ -14,7 +14,7 @@ The [settings seam](2026-07-28-user-settings-seam.md) shipped without a producti
|
||||
|
||||
**Per-request resolution, not fiber rebuilds.** The adapters take an options thunk (and a per-stream credential resolver) instead of frozen construction facts, resolving once per operation — the Pi pattern, with its tested semantics: two requests straddling a change see two configurations, one request resolves exactly once, and an in-flight stream keeps the facts it started with. This deletes the entire swap machinery a rebuild design needs (`DUPLICATE_ADAPTER` ordering, `NO_ADAPTER` windows, a deferred-activation state machine) and makes a missing key a *request-time* actionable failure (`MISSING_CREDENTIAL` naming every entry point) while the route stays registered and the catalog stays browsable. The one registration-captured fact — the retry policy the `ctx.llm` registry snapshots at `registerAdapter` (plus pi-ai's route *set*) — re-registers the same adapter instance in one synchronous section when it changes.
|
||||
|
||||
**Secrets are references, values live behind `ctx.credentials`.** Configuration (both planes) carries `apiKeyEnv: DEEPSEEK_API_KEY`; the three-package credential seam resolves it per operation. `credentials-local` layers the live process environment (read-only, wins — a launch-time override is operator intent and must be *visibly* read-only, so shadowed writes reject instead of appearing to succeed) over `$DSH_HOME/.env` (writable, byte-preserving line edits, a quoting ladder dotenv reads back verbatim, wholesale snapshot replacement on reload so a deleted entry never lingers — the Claude Code additive-reapply lesson). Resolution order in the adapters is literal `apiKey` first (preserving the historical `config.apiKey ?? env` observable semantics), then the seam, then — only without a mounted seam — the raw environment variable.
|
||||
**Secrets are references, values live behind `ctx.credentials`.** Configuration (both planes) carries `apiKeyEnv: DEEPSEEK_API_KEY`; the three-package credential seam resolves it per operation. `credentials-local` layers the live process environment (read-only, wins — a launch-time override is operator intent and must be *visibly* read-only, so shadowed writes reject instead of appearing to succeed) over the provider-managed document (writable, wholesale snapshot replacement on reload so a deleted entry never lingers — the Claude Code additive-reapply lesson). That document was `$DSH_HOME/.env` in dotenv form; the [credentials document split](2026-08-04-credentials-yaml-and-user-environment-layer.md) later moved it to `$DSH_HOME/.credentials.yaml` and freed the old path to become the user's environment layer. Resolution order in the adapters is literal `apiKey` first (preserving the historical `config.apiKey ?? env` observable semantics), then the seam, then — only without a mounted seam — the raw environment variable.
|
||||
|
||||
**Per-plugin namespaces, schema ≡ `Config`.** Each adapter registers its own namespace (`llm-deepseek`, `llm-pi-ai`) with its plugin `Config` schema and its `cordis.yml` entry as the composition `base` — a settings section is the same YAML shape as the entry config, and `resolveAdapterOptions`/`resolveProfiles` stay the one explicit resolve step for both. A live snapshot failing a beyond-schema bound keeps the last good facts (the seam's last-good philosophy extended one level up); the entry config itself still fails load. pi-ai's `providers` became a dict keyed by route so base and user layers merge per provider and the route set is structural; the array shape fails loud with migration directions, and an empty dict is the valid dormant posture — a composition ships the adapter bare and every route stays a user-plane decision.
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ Status: implemented
|
||||
|
||||
**按请求解析,而非重建 fiber。**适配器改为接收一个 options thunk(外加按流调用的凭据解析器),不再持有冻结的构造期事实,每个操作解析一次——即 Pi 的模式,连同其经测试固定的语义:跨越一次变更的两个请求看到两份配置,一个请求恰好解析一次,进行中的流保持其起始事实。这删掉了重建式设计所需的整套切换机制(`DUPLICATE_ADAPTER` 顺序问题、`NO_ADAPTER` 窗口、延迟激活状态机),并把密钥缺失变成*请求时*可行动的失败(`MISSING_CREDENTIAL` 点名每个配置入口),同时路由保持注册、catalog 保持可浏览。唯一在注册期捕获的事实——`ctx.llm` 注册表在 `registerAdapter` 时快照的重试策略(外加 pi-ai 的路由*集合*)——在其变化时于一个同步区段内原地重新注册同一适配器实例。
|
||||
|
||||
**机密是引用,值藏在 `ctx.credentials` 背后。**配置(两个面)携带 `apiKeyEnv: DEEPSEEK_API_KEY`;三包凭据 seam 按操作解析它。`credentials-local` 把活跃进程环境(只读、优先——启动时覆盖是操作者意图,必须*可见地*只读,因此被遮蔽的写入直接拒绝而不是表面成功)叠加在 `$DSH_HOME/.env` 之上(可写、保字节行级编辑、dotenv 能逐字读回的引号阶梯、重载时整体替换快照使删除的条目绝不滞留——来自 Claude Code 增量重放(additive reapply)的教训)。适配器内的解析顺序为:字面 `apiKey` 优先(保留历史 `config.apiKey ?? env` 的可观察语义),然后是 seam,最后——仅在未挂载 seam 时——原始环境变量。
|
||||
**机密是引用,值藏在 `ctx.credentials` 背后。**配置(两个面)携带 `apiKeyEnv: DEEPSEEK_API_KEY`;三包凭据 seam 按操作解析它。`credentials-local` 把活跃进程环境(只读、优先——启动时覆盖是操作者意图,必须*可见地*只读,因此被遮蔽的写入直接拒绝而不是表面成功)叠加在 provider 管理的文档之上(可写、重载时整体替换快照使删除的条目绝不滞留——来自 Claude Code 增量重放(additive reapply)的教训)。该文档当时是 dotenv 形式的 `$DSH_HOME/.env`;[凭据文档拆分](2026-08-04-credentials-yaml-and-user-environment-layer.md)后来把它移到 `$DSH_HOME/.credentials.yaml`,并让旧路径转为用户的环境层。适配器内的解析顺序为:字面 `apiKey` 优先(保留历史 `config.apiKey ?? env` 的可观察语义),然后是 seam,最后——仅在未挂载 seam 时——原始环境变量。
|
||||
|
||||
**按插件划分 namespace,schema ≡ `Config`。**每个适配器注册自己的 namespace(`llm-deepseek`、`llm-pi-ai`),schema 用其插件 `Config` schema,组合 `base` 用其 `cordis.yml` 条目——settings 分节与 entry 配置是同一种 YAML 形状,`resolveAdapterOptions`/`resolveProfiles` 对两者仍是唯一的显式 resolve 步骤。存活快照若违反 schema 之外的约束,则保留最后可用事实(seam 的最后可用值哲学向上延伸一层);entry 配置本身仍会加载失败。pi-ai 的 `providers` 改为以路由为键的字典,base 层与用户层因此按提供方合并,路由集合也由结构直接表达;数组形状响亮失败并给出迁移指引,而空字典是合法的休眠姿态——组合可以裸挂该适配器,把每一条路由都留给用户面决定。
|
||||
|
||||
|
||||
@@ -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 .agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md
|
||||
2026-07-30-credential-boundaries-and-atomic-registration.md: 6fe5f554acbfd804db9625fcaa794d513c8799c4
|
||||
2026-07-30-credential-boundaries-and-atomic-registration.zh.md: 3eb3b022064124aad2a389abba3063af4e2110fa
|
||||
2026-07-30-credential-boundaries-and-atomic-registration.md: a093a78d7e3dafe218eb8f1013f226de0d6d9a0b
|
||||
2026-07-30-credential-boundaries-and-atomic-registration.zh.md: 208642af34b5bda07a4e02bc991a655c5bb1fa20
|
||||
|
||||
@@ -14,7 +14,7 @@ Two request-path defects sat beside them. DeepSeek's per-request resolution kept
|
||||
|
||||
## Decision
|
||||
|
||||
**`$DSH_HOME/.env` belongs to the credential provider alone.** No surface loads it into `process.env`. 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 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](2026-08-04-credentials-yaml-and-user-environment-layer.md) 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.
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ Status: implemented
|
||||
|
||||
## 决策
|
||||
|
||||
**`$DSH_HOME/.env` 只归凭据提供方所有。**没有任何一个面会把它加载进 `process.env`。真正的启动环境,以及调用目录中由 bin 加载的 `.env`,仍然是那一层只读的环境来源,因此不挂载该提供方的组合,解析密钥的方式与从前完全一致,而存下的密钥跨重启仍然来源于文件、仍然可写——这一点由 Loader 组合中的一次真实重启来证明,而不是靠对 `describe()` 的单元断言。
|
||||
**凭据文档只归凭据提供方所有。**没有任何一个面会把它加载进 `process.env`。当时该文档是 `$DSH_HOME/.env`;[凭据文档拆分](2026-08-04-credentials-yaml-and-user-environment-layer.md)后来把它移到 `$DSH_HOME/.credentials.yaml`,因此如今被加载的正是那条旧路径——作为用户的普通环境层,其中不含任何 provider 管理的密钥。真正的启动环境,以及调用目录中由 bin 加载的 `.env`,仍然是那一层只读的环境来源,因此不挂载该提供方的组合,解析密钥的方式与从前完全一致,而存下的密钥跨重启仍然来源于文件、仍然可写——这一点由 Loader 组合中的一次真实重启来证明,而不是靠对 `describe()` 的单元断言。
|
||||
|
||||
**存下的凭据对模型没有边界,而 README 就是这么写的。**`0700` 目录下的 `0600` 挡得住其他 OS 用户;模型的 bash 与文件系统工具正是以同一用户身份运行,而已交付的默认值不约束任何东西。harness 真正守住的更窄,也就照这个宽度写下来:没有任何一个面会把该文档提升进 `process.env`,模型也从不会拿到它的解析后路径,因此要拿到这个值,需要刻意去读一条并未交给它的路径。OS 钥匙串(keychain)提供方——一个模型的进程根本读不到的存储——被记录为真正的答案,而不是靠一个残缺的方案去暗示它。
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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 .agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.md
|
||||
2026-08-04-credentials-yaml-and-user-environment-layer.md: f1bca69820d03fe67849bd7c7159489ac27cd2e0
|
||||
2026-08-04-credentials-yaml-and-user-environment-layer.zh.md: 7e6714abd33baad1fb2a570514754b467fcf8bd5
|
||||
@@ -0,0 +1,50 @@
|
||||
# Agent Note: Splitting the credential store from the user environment layer
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-04-credentials-yaml-and-user-environment-layer.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
`$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 TUI and the web 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](2026-07-29-request-level-llm-config-credentials.md) 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:
|
||||
|
||||
```yaml
|
||||
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`](../../../../packages/ui/app-boot/README.md) loads the invoking directory's `.env` and then the Harness home's, giving `user < project < inherited` — `process.loadEnvFile` never replaces a name already set, which is what the load order exploits and what the app-boot tests pin across all three layers. 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 is unchanged this round: the live process environment still wins read-only over the file, and `set`/`unset` still reject a write the environment would shadow. Whether a provider-managed store should instead win over the environment is a separate decision, deliberately not taken here.
|
||||
|
||||
There is no migration. The product is unreleased, and a key already in `$DSH_HOME/.env` keeps resolving through the new environment layer — as a read-only `env` source that shadows the stored one, which is exactly what the diagnostics say.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Given up: a key left in `$DSH_HOME/.env` is now hoisted into `process.env`, so it reaches subprocesses under the [subprocess credential scrub](../../../../packages/subprocess/subprocess/README.md) rather than staying inside the provider. That is the honest meaning of "ordinary environment layer"; a secret the Harness should own and isolate belongs in `.credentials.yaml`, which is never hoisted.
|
||||
- Given up: the same key shadows `.credentials.yaml` and makes the web Models page's write reject. The seam already reports `source: 'env', writable: false` for that state, and the rejection message now names the loaded `.env` as a place to unset it.
|
||||
- Bought: a non-secret in the user's `.env` finally takes effect, which was the original defect; the document format can reject what it cannot serve; and `0600` covers a file that holds only secrets instead of a file users are told to put ordinary configuration in.
|
||||
- Not taken: a read-time permission check that fails startup when `.credentials.yaml` is more permissive than `0600`. Creation and atomic replacement already pin the mode; making a hand-created file fatal is a separable security decision.
|
||||
- The `0600` boundary still stops other OS users and not the model, unchanged by this split — the [provider README](../../../../packages/credentials/credentials-local/README.md) 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](../../../../packages/ui/app-boot/README.md) 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.
|
||||
@@ -0,0 +1,50 @@
|
||||
# Agent Note: 把凭据存储与用户环境层拆开
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-04-credentials-yaml-and-user-environment-layer.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
`$DSH_HOME/.env` 同时承担了两件互不相容的工作。它是 [`credentials-local`](../../../../packages/credentials/credentials-local/README.md) 的可写密钥存储,因此任何表层都不能把它提升进 `process.env`——一旦提升,每个已存密钥都会读作只读的启动时覆盖,从而阻断从 TUI 与 Web 页面轮换密钥。但它的文件名和 dotenv 格式承诺的是一个环境文件,于是用户把非密钥值放进去,而那些值哪儿也到不了:同一个文件里,一个能用的 `DEEPSEEK_API_KEY` 旁边的 `DEEPSEEK_BASE_URL` 会被静默忽略,因为只有凭据 provider 读这份文档,而它只寻址凭据引用。
|
||||
|
||||
一个文件无法既是由 Harness 拥有并隔离的存储,又是按普通环境规则传播的层。[请求级凭据决策](2026-07-29-request-level-llm-config-credentials.md)当初选择 dotenv 是为了对齐同类产品的 home `.env`,而这种混同直到有非密钥值需要用同一个文件时才暴露出来。
|
||||
|
||||
## Decision
|
||||
|
||||
两件工作在 Harness home 下拆成两个文件。
|
||||
|
||||
**`.credentials.yaml` 是 provider 管理的存储。** 一个从 `CredentialRef` 到非空字符串的严格 YAML mapping,没有 `version` 字段,也没有包装层:
|
||||
|
||||
```yaml
|
||||
DEEPSEEK_API_KEY: sk-…
|
||||
OPENAI_API_KEY: sk-…
|
||||
```
|
||||
|
||||
因为该文档只存放凭据、别无他物,任何偏离都是拒绝而不是跳过条目:非 mapping 的根、非 POSIX 标识符的键、非字符串值、空字符串、重复键以及格式错误的 YAML 全部失败——启动时和写入时响亮失败,运行期热重载则告警并保留最后可用快照。被静默忽略的键读起来就是「我存进去的密钥没有生效」,而这正是本次变更要消除的失败。dotenv 物理行编辑器被替换为对已解析文档打补丁,因此注释与未触及条目的排版都会保留,任何字符串值都能往返(含多行),也不会再有条目因为缺少可用引号样式而不可写。写锁、read-modify-write、`0700` 目录下的 `0600` 原子写、精确路径 watcher、按内容相等抑制自写、以及 dispose 时的完全停稳,均保持不变。
|
||||
|
||||
**`$DSH_HOME/.env` 是用户的普通环境层。** [`dsh-app-boot`](../../../../packages/ui/app-boot/README.md) 中的 `loadLayeredEnv` 先加载调用目录的 `.env`,再加载 Harness home 的,得到 `用户 < 项目 < 继承`——`process.loadEnvFile` 从不替换已经设置的名字,加载顺序正是利用了这一点,app-boot 的测试也把三层一起钉住。Harness home 在两个文件加载*之前*就从继承的环境解析完毕,因此项目 `.env` 无法改变读取哪份用户文档。只有产品 CLI(命令行界面)叠加这两个文件;SDK 与示例 bin 仍通过 `loadEnv` 加载各自的目录,绝不继承开发者的 `$DSH_HOME`。
|
||||
|
||||
本轮不改凭据优先级:活跃进程环境仍然只读地优先于文件,`set`/`unset` 仍然拒绝会被环境遮蔽的写入。provider 管理的存储是否应当反过来压过环境,是另一个决策,此处刻意不作。
|
||||
|
||||
不做迁移。产品尚未发布,而已经放在 `$DSH_HOME/.env` 里的密钥会继续通过新的环境层解析——作为只读的 `env` 来源遮蔽已存储的那一份,诊断给出的也正是这个结论。
|
||||
|
||||
## Consequences
|
||||
|
||||
- 放弃的:留在 `$DSH_HOME/.env` 里的密钥现在会被提升进 `process.env`,因而会按[子进程凭据清洗](../../../../packages/subprocess/subprocess/README.md)的规则抵达子进程,而不再留在 provider 内部。这就是「普通环境层」的诚实含义;需要由 Harness 拥有并隔离的密钥属于 `.credentials.yaml`,后者永不提升。
|
||||
- 放弃的:同一个键会遮蔽 `.credentials.yaml`,并让 Web Models 页的写入被拒。seam 对这种状态本来就报告 `source: 'env', writable: false`,而拒绝信息现在会把已加载的 `.env` 一并指为需要清除的位置。
|
||||
- 换来的:用户 `.env` 里的非密钥值终于生效,这正是最初的缺陷;文档格式可以拒绝它无法承担的内容;`0600` 保护的是一个只存密钥的文件,而不是一个我们同时叫用户往里写普通配置的文件。
|
||||
- 未采纳的:在读取时校验权限、并在 `.credentials.yaml` 宽于 `0600` 时让启动失败。创建与原子替换已经钉住了模式;让手工创建的文件直接致命是一个可分离的安全决策。
|
||||
- `0600` 这条边界仍然只挡其他 OS 用户、挡不住模型,本次拆分未改变这一点——该限制及 keychain provider 的延后项归 [provider README](../../../../packages/credentials/credentials-local/README.md) 所有。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**保留单一的 `$DSH_HOME/.env`,让 CLI 去提升它。** 否决:提升存储本身正是让已存密钥无法轮换的原因,这也是 [app-boot 当初记录该排除](../../../../packages/ui/app-boot/README.md)的理由。冲突来自这个文件的两份工作,而不是加载器。
|
||||
|
||||
**`$DSH_HOME/.credentials.env`——第二个 dotenv 文件。** 否决:dotenv 适合环境层,却无法表达「一份按凭据引用索引的受管文档」。它无法拒绝非字符串或无法寻址的键,而且它的行编辑器本来就会拒绝无法加引号的值,留下可读却不可写的条目。
|
||||
|
||||
**给新文档加 `version` 字段。** 否决:该格式只有一个受 schema 约束的字符串 mapping,没有需要判别的历史变体。在未发布阶段,直接修改结构并拒绝旧结构,好过提前承诺迁移协议。
|
||||
|
||||
**首次运行时把形似凭据的键从 `$DSH_HOME/.env` 迁出。** 否决:迁移代码会把短命格式变成长期维护面,而判断一个未知文件里哪些键是密钥,恰恰是本次拆分要消除的歧义。旧文件继续作为环境工作,这是诚实的结果,而不是静默的结果。
|
||||
|
||||
**彻底取消用户 `.env` 层,只保留继承的环境。** 在此处否决为超出范围:它本身是自洽的设计(层次更少、每个值只有一处来源),但会移除用户已有的工作流,而分层问题属于那个被延后的优先级决策,不属于本次拆分。
|
||||
Reference in New Issue
Block a user