Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input
This commit is contained in:
+2
-2
@@ -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: 2d132c0fd0ab2205ca013b49226a9764293cc11d
|
||||
2026-07-29-request-level-llm-config-credentials.md: 238400ea41f25a716729d1721c113645c2c8ba72
|
||||
2026-07-29-request-level-llm-config-credentials.zh.md: b0d04d4303bf0ccf5ebc74af8c4a3e493f861d63
|
||||
+1
-1
@@ -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. Adapters resolve the reference through the seam, or — only without a mounted seam — through the environment layers.
|
||||
|
||||
**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.
|
||||
|
||||
|
||||
+1
-1
@@ -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`,并让旧路径转为用户的环境层。适配器通过 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
-2
@@ -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: b58a9e68bb1fa36282ad270542b6a64a0b014726
|
||||
2026-07-30-credential-boundaries-and-atomic-registration.md: 94b32c3cfaa3e1c5059573881a2f393d29aed3ac
|
||||
2026-07-30-credential-boundaries-and-atomic-registration.zh.md: 2506ce391c9125323faf107e3c04c52785e0cc98
|
||||
+3
-3
@@ -10,15 +10,15 @@ English | [中文](2026-07-30-credential-boundaries-and-atomic-registration.zh.m
|
||||
|
||||
Review found the credential path leaking 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-local defects that same review round fixed (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/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's per-request resolution kept connection facts in a last-good snapshot but re-read the literal `apiKey` from the raw configuration, so a settings generation the resolver rejected could still put its key on 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.
|
||||
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
|
||||
|
||||
**`$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.
|
||||
|
||||
**One request, one generation.** DeepSeek's resolved snapshot carries the credential facts (literal key and 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.
|
||||
**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.
|
||||
|
||||
|
||||
+3
-3
@@ -14,15 +14,15 @@ Status: implemented
|
||||
|
||||
在读取一侧,文件的 `0600` 权限挡得住其他 OS 用户,却挡不住模型:它的 bash 与文件系统工具就以同一个用户身份运行。
|
||||
|
||||
与之并排的还有两个请求路径缺陷。DeepSeek 的按请求解析把连接事实保存在最后可用快照里,却仍从原始配置重新读取字面 `apiKey`,于是被 resolver 拒绝的那一代设置,照样能把自己的密钥送到上一代的端点上。配置了 `apiKeyEnv` 却解析不到值时,pi-ai 会把 `undefined` 交给 SDK,让 pi-ai 自己的环境发现拿一个毫不相干的提供方密钥完成鉴权——那是另一个租户,账单还悄悄记在它头上。而且它的路由替换是先释放旧注册、再创建新注册:只要有一条路由已被别的适配器占有,现有路由就会被全部丢掉,此后事实缓存可能与注册表中的事实相等,于是把配置改回可用状态也不会重新生效。
|
||||
与之并排的还有两个请求路径缺陷。DeepSeek 分别解析连接事实与凭据事实,因此被 resolver 拒绝的那一代设置仍可能把自己的凭据选择与上一代的端点配在一起。配置了 `apiKeyEnv` 却解析不到值时,pi-ai 会把 `undefined` 交给 SDK,让 pi-ai 自己的环境发现拿一个毫不相干的提供方密钥完成鉴权——那是另一个租户,账单还悄悄记在它头上。而且它的路由替换是先释放旧注册、再创建新注册:只要有一条路由已被别的适配器占有,现有路由就会被全部丢掉,此后事实缓存可能与注册表中的事实相等,于是把配置改回可用状态也不会重新生效。
|
||||
|
||||
## 决策
|
||||
|
||||
**`$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)提供方——一个模型的进程根本读不到的存储——被记录为真正的答案,而不是靠一个残缺的方案去暗示它。
|
||||
|
||||
**一次请求,一代设置。**DeepSeek 解析出的快照在端点旁一并携带凭据事实(字面密钥与引用),`resolveApiKey` 接收这份快照,而不再重新读取配置。被拒绝的那一代如今完全不再贡献任何东西。只有当一个 profile 完全没有点名凭据时,pi-ai 才交给提供方原生的发现流程;配置了引用却解析不到,就以 `MISSING_CREDENTIAL` 失败,并点名该路由与该引用。启动时的凭据探测被删除:它可能在凭据服务挂载之前就运行,并把每一种失败都报成密钥缺失,而第一次请求本就会给出准确的错误。
|
||||
**一次请求,一代设置。**DeepSeek 解析出的快照在端点旁一并携带凭据引用,`resolveApiKey` 接收这份快照,而不再重新读取配置。被拒绝的那一代如今完全不再贡献任何东西。只有当一个 profile 完全没有点名凭据时,pi-ai 才交给提供方原生的发现流程;配置了引用却解析不到,就以 `MISSING_CREDENTIAL` 失败,并点名该路由与该引用。启动时的凭据探测被删除:它可能在凭据服务挂载之前就运行,并把每一种失败都报成密钥缺失,而第一次请求本就会给出准确的错误。
|
||||
|
||||
**路由替换是注册表的操作,不是调用方的一串步骤。**`registerAdapter` 返回一个携带 `replace(providers)` 的句柄:候选集合先被完整校验(冲突、名称、提供方元数据),再在一个同步区段内完成替换。被拒绝的替换会让先前的路由保持注册并继续服务,而调用方的事实缓存只有在注册表确实持有新集合之后才会推进,因此改回可用配置时会重新生效。pi-ai 的注册事实按提供方排序,因此仅仅调换键顺序的设置文档不再算作路由变更。
|
||||
|
||||
|
||||
+6
@@ -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-configuration-source-ownership.md
|
||||
2026-08-04-configuration-source-ownership.md: ef30a22c120af1437f348e52843e1dd45c9837ae
|
||||
2026-08-04-configuration-source-ownership.zh.md: c1567c6e823a0bc8ed8d1f9ed50a11a5d204ede8
|
||||
@@ -0,0 +1,73 @@
|
||||
# Agent Note: One ordering for configuration sources, and what a discovered file may not decide
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-04-configuration-source-ownership.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
`$DSH_HOME/.env` had just [become an ordinary environment layer](2026-08-04-credentials-yaml-and-user-environment-layer.md), which left the harness resolving user-facing values from a flattened `process.env` that could no longer say where a value came from. Three consequences followed.
|
||||
|
||||
A key stored through the web page stayed shadowed by an older key in the user's own `.env`, because the credential provider compared "the environment" against its file and the environment now included that file. The migration dead end the split was supposed to remove had simply moved.
|
||||
|
||||
An endpoint could be redirected by the project. The invoking directory's `.env` is materialized like every other layer, and a base URL decides where a resolved API key is sent — so a `DEEPSEEK_BASE_URL` written into a workspace the model can edit would send the user's own credential, and the prompts carrying their code, to whatever host that file named. Nothing about the flattened view could distinguish that from the operator exporting the same variable.
|
||||
|
||||
And `!!js process.env.X` in the shipped composition made the same value reachable twice: once through the entry config and once through whatever ladder its consumer applied, with the winner decided by layer order rather than by what the value means.
|
||||
|
||||
## Decision
|
||||
|
||||
**One ordering for non-secret values.** Every configurable value that is not itself a credential resolves in the same order; the domains differ only in which tiers exist.
|
||||
|
||||
```text
|
||||
explicit for this run per-operation override, CLI argument
|
||||
> user settings settings.yaml
|
||||
> composition profile bundles, user patch layers, --patch overlays
|
||||
> this launch's shell inherited process environment
|
||||
> discovered file <invocation cwd>/.env, then $DSH_HOME/.env
|
||||
> defaults schema default, provider public default
|
||||
```
|
||||
|
||||
Settings sit above composition because that is what the [settings seam](2026-07-28-user-settings-seam.md) does: a plugin registers its cordis entry config as the `base` layer and the user's section layers over it, and the seam cannot tell a value a profile's bundles set from one its user patch layer or a `--patch` overlay set — all arrive as entry config. The product CLI has no lever above stored settings, so a deployment that must pin a field against a user's settings ships its own bin or loader tree, or mounts no settings provider at all. Composition still outranks the environment, so a stale `DEEPSEEK_BASE_URL` in a shell cannot rewrite a configured endpoint.
|
||||
|
||||
**Credentials keep a narrower, separate ordering**, and this note does not unify them:
|
||||
|
||||
```text
|
||||
inherited process environment (read-only, wins)
|
||||
> $DSH_HOME/.credentials.yaml (provider-managed, writable)
|
||||
> <invocation cwd>/.env
|
||||
> $DSH_HOME/.env
|
||||
```
|
||||
|
||||
The launching environment wins because `DEEPSEEK_API_KEY=… dsh`, a CI secret, and a container `-e` are the one override an operator must be able to apply per run without editing machine state, and because it cannot be edited from inside it must be *visibly* read-only. Configuration is meant to carry only the *reference* — which name to resolve — and that name follows the non-secret ordering above.
|
||||
|
||||
**The project the harness is launched in is trusted, by default and without a prompt.** A checkout may carry its own endpoint, its own ordinary variables, and its own key; the key ranks below the managed store, so a key stored through the Models page is never displaced by one a checkout happens to contain. `EnvironmentSnapshot.getFrom(name, sources)` still searches only the layers a caller names, and omitting one is a refusal rather than a demotion — the mechanism exists for the decisions where a layer must be unreachable, not because the project is one of them today.
|
||||
|
||||
**Trust does not extend to changing the harness itself.** `loadLayeredEnv` rejects, at load and before anything is materialized, any `.env` that sets a variable governing how a process launches (`PATH`, `SHELL`, `NODE_OPTIONS`, `LD_PRELOAD`), what code a runtime executes before the program it was asked to run (`BASH_ENV`, `PERL5OPT`, `PYTHONSTARTUP`, `RUBYOPT`, `JAVA_TOOL_OPTIONS`, the Git hook commands), where model-visible instructions load from (the whole `DSH_*` namespace, `HOME`, `XDG_*`), or how the network is reached and trusted (proxy and CA variables). Matching is case-insensitive, so `https_proxy` is not a bypass.
|
||||
|
||||
The line is that these take effect with no user action, before any turn, outside the permission policy and the sandbox. `DSH_PERMISSION_MODE` would switch off the approvals that make trusting a project meaningful at all, and `BASH_ENV` runs a file of the project's choosing on every single `bash -c` the bash tool issues — the project's code running under the agent's policy is the deal; the project rewriting that policy is not. Enumerating these is a losing game one variable at a time, which is why the whole `DSH_*` namespace is denied rather than an audited subset, and why the list is organised by what a variable *does* rather than by which runtime owns it. There is no opt-out: an escape hatch would have to be readable from somewhere, and anything a discovered file could set is the hole itself.
|
||||
|
||||
**`packages/util/environment` owns the snapshot**, deliberately as a utility rather than a three-package capability seam. The snapshot is frozen before Cordis starts and injected once by the launcher, so there is no runtime implementation to swap; consumers need types and pure functions, which a `util/` package gives them without depending on a UI package. `environmentOf(ctx)` returns the launcher's snapshot, or the inherited environment as the only layer — an SDK host or bare `cordis.yml` discovered no files, so its single layer really is what it was launched with, and the same trusted lookups keep working there unchanged.
|
||||
|
||||
**`verify-config-source-ownership`** is a narrow tripwire for the ordinary single-line form of an `apiKey`/`baseURL`/`headers` environment inline in shipped Cordis configuration. Removing those inlines is what makes the deployment tier meaningful — with the shipped tree silent on `baseURL`, a present value means a human or deployment set it. Adapters own actual resolution; the gate makes no repository-wide claim about `process.env` access.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The web credential form now takes effect against an older key in the user's `.env`; only a key exported in the launching shell still makes it read-only, and the diagnostic says so.
|
||||
- A `.env` holding `DSH_*`, `PATH`, or a proxy variable fails the launch instead of being applied. Developers keeping switches in a repository `.env` move them to their shell — a deliberate, loud break.
|
||||
- Composition is no longer overridable by a stale shell endpoint. It is still overridable by a user's stored `settings.yaml`, which is the settings seam's layering and not something this note changes; the product CLI offers no flag above it, so a deployment that must win against stored settings owns its own bin or loader tree.
|
||||
- Not solved: the layers are still materialized into `process.env`, so ordinary project variables continue to reach child processes under the subprocess scrub. Bootstrap variables cannot come from a file at all; the environment package records the remaining subprocess reach as a limitation.
|
||||
- Exa and Perplexity still capture their key at load time rather than through the credential seam. They no longer read raw `process.env` — they resolve through the trusted layers — but converting them to per-request seam resolution is separate work.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Unify credentials into the non-secret ordering, by who authored each source.** Attempted and abandoned: it reads well, but the settings seam already fixes composition *below* the user section, so "authored by deployment" is not a tier the seam can express — and moving `.credentials.yaml` above the launching environment would take away the one override CI, containers, and a per-run `DEEPSEEK_API_KEY=…` depend on. Two orderings that each say why they are shaped that way beat one that describes neither accurately.
|
||||
|
||||
**Withhold routing and credentials from the invoking project until it is explicitly trusted.** Rejected as the product's stance: a checkout is trusted by default, with no prompt and no stored trust record. The residual is real and worth naming — cloning a repository that carries a `.env` naming another endpoint or key routes that session through it — and a later project-trust gate is where that gets addressed, not a rule that makes the common case require ceremony.
|
||||
|
||||
**Audit an allowlist of `DSH_*` variables a `.env` may set.** Rejected: the list would have to be re-audited on every new switch, and the failure mode of forgetting is silent. Denying the namespace fails safe.
|
||||
|
||||
**Rank a bootstrap variable below the process layer instead of rejecting it.** Rejected: `PATH` and `NODE_OPTIONS` have no meaningful "loser" behavior — a user who put one in a `.env` believes it applies, and silently ignoring it is the "my setting has no effect" failure this whole series exists to remove.
|
||||
|
||||
**Build the snapshot as a three-package capability seam (`environment` / `environment-local` / consumers).** Rejected as premature: the producer runs before Cordis exists and there is no second implementation to select. The repository rule is to not split preemptively.
|
||||
|
||||
**Stop materializing the layers into `process.env`.** Deferred, not rejected: it would keep project variables out of child processes entirely, but it silently breaks any user patch layer that reads `!!js process.env.X`. The snapshot is already the authority for everything the harness resolves, so this can land later without changing any ladder.
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
# Agent Note: 配置来源的统一顺序,以及被发现的文件不得决定什么
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-04-configuration-source-ownership.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
`$DSH_HOME/.env` 刚刚[变成普通环境层](2026-08-04-credentials-yaml-and-user-environment-layer.md),这使得 harness 解析面向用户的值时面对的是一个压平的 `process.env`,再也说不清某个值来自哪里。由此产生三个后果。
|
||||
|
||||
通过 Web 页面存下的密钥仍然被用户自己 `.env` 里更旧的密钥遮蔽,因为凭据 provider 是拿「环境」与自己的文件比较,而现在环境包含了那个文件。这次拆分本该消除的迁移死路,只是换了个位置。
|
||||
|
||||
endpoint 可以被项目重定向。调用目录的 `.env` 和其他层一样会被物化,而 base URL 决定已解析的 API key 发往何处——于是写进模型可编辑工作区的 `DEEPSEEK_BASE_URL`,会把用户自己的凭据、以及承载其代码的提示词,一起发给该文件指定的任何主机。压平的视图无法把这件事和运维显式 export 同一个变量区分开。
|
||||
|
||||
而已交付组合里的 `!!js process.env.X` 让同一个值有两条抵达路径:一条经 entry config,一条经消费方各自的 ladder,胜负取决于层序而非这个值的语义。
|
||||
|
||||
## Decision
|
||||
|
||||
**非密钥值走同一条顺序。** 每个本身不是凭据的可配置值都按同一顺序解析;各领域的差别只在于哪些层存在。
|
||||
|
||||
```text
|
||||
explicit for this run per-operation override, CLI argument
|
||||
> user settings settings.yaml
|
||||
> composition profile bundles, user patch layers, --patch overlays
|
||||
> this launch's shell inherited process environment
|
||||
> discovered file <invocation cwd>/.env, then $DSH_HOME/.env
|
||||
> defaults schema default, provider public default
|
||||
```
|
||||
|
||||
自上而下依次是:本次运行的显式意图、用户 settings、composition、本次启动的 shell、被发现的文件、默认值。
|
||||
|
||||
settings 在 composition 之上,因为 [settings seam](2026-07-28-user-settings-seam.md) 就是这么做的:插件把自己的 cordis entry config 注册为 `base` 层,用户 section 叠加其上,而 seam 无法区分某个值是 profile 的 bundle 设的,还是它的用户 patch 层或某个 `--patch` overlay 设的——它们都以 entry config 的形式抵达。产品 CLI(命令行界面)没有高于已存 settings 的手段,因此需要把某字段钉死、不被用户已存 settings 覆盖的部署方,应自带 bin 或 loader 配置树,或者干脆不挂载 settings provider。composition 仍然高于环境,所以 shell 里陈旧的 `DEEPSEEK_BASE_URL` 无法改写已配置的 endpoint。
|
||||
|
||||
**凭据保留一条更窄的独立顺序**,本 Note 不把它并入上表:
|
||||
|
||||
```text
|
||||
inherited process environment (read-only, wins)
|
||||
> $DSH_HOME/.credentials.yaml (provider-managed, writable)
|
||||
> <invocation cwd>/.env
|
||||
> $DSH_HOME/.env
|
||||
```
|
||||
|
||||
继承环境优先,因为 `DEEPSEEK_API_KEY=… dsh`、CI 机密与容器 `-e` 是运维必须能按次施加、且无需改动机器状态的那一种覆盖;而它无法从进程内部修改,就必须*可见地*只读。配置本应只携带*引用*——解析哪个名字——该名字本身遵循上面的非密钥顺序。
|
||||
|
||||
**harness 被启动于其中的项目默认可信,且不做询问。** 一个 checkout 可以携带自己的 endpoint、自己的普通变量和自己的密钥;密钥排在受管存储之下,因此通过 Models 页存下的密钥绝不会被 checkout 中恰好带有的那一个顶掉。`EnvironmentSnapshot.getFrom(name, sources)` 仍然只搜索调用方点名的层,省略某层仍是拒绝而不是降级——该机制是为「某一层必须不可达」的那些决策准备的,而项目层今天不在其列。
|
||||
|
||||
**信任不延伸到改变 harness 本身。** `loadLayeredEnv` 会在加载时、且在物化任何内容之前,拒绝任何设置了下列变量的 `.env`:决定进程如何启动的(`PATH`、`SHELL`、`NODE_OPTIONS`、`LD_PRELOAD`)、决定运行时在执行被要求运行的程序之前先执行哪些代码的(`BASH_ENV`、`PERL5OPT`、`PYTHONSTARTUP`、`RUBYOPT`、`JAVA_TOOL_OPTIONS`、Git 的钩子命令)、决定模型可见指令从哪里加载的(整个 `DSH_*` 命名空间、`HOME`、`XDG_*`),以及决定网络如何抵达与信任的(proxy 与 CA 变量)。匹配不区分大小写,因此 `https_proxy` 不是绕过手段。
|
||||
|
||||
这条界线在于:它们无需任何用户动作、在任何一轮开始之前、且在权限策略与沙箱之外就生效。`DSH_PERMISSION_MODE` 会关掉让「信任项目」根本成立的那道审批,而 `BASH_ENV` 会在 bash 工具发出的每一次 `bash -c` 上执行项目指定的文件——项目的代码在 agent 的策略下运行是约定,项目改写那份策略不是。一个变量一个变量地枚举是必输的游戏,所以整个 `DSH_*` 命名空间被拒绝而不是只拒绝一份经审查的子集,也所以这份清单是按变量*做什么*而不是按哪个运行时拥有它来组织的。不设逃生门:逃生门本身总得从某处读取,而任何被发现的文件能设置的东西,就是那个漏洞本身。
|
||||
|
||||
**`packages/util/environment` 拥有该快照**,刻意做成 utility 而不是三包能力 seam。快照在 Cordis 启动前就冻结,并由启动器一次性注入,因此不存在需要切换的运行时实现;消费方需要的只是类型和纯函数,而 `util/` 包能提供这些且不必依赖 UI 包。`environmentOf(ctx)` 返回启动器的快照,或者返回只含继承环境的那一层——SDK 宿主或裸 `cordis.yml` 从未发现过任何文件,它那唯一一层确实就是它被启动时的环境,因此同样的受信查询在那里原样继续工作。
|
||||
|
||||
**`verify-config-source-ownership`** 仅作为一道窄门禁,检查已交付 Cordis 配置中从环境内联 `apiKey`/`baseURL`/`headers` 的普通单行写法。删除这些内联正是「部署层」得以成立的原因——已交付配置树对 `baseURL` 保持沉默之后,「有值」就意味着「人或部署设过它」。实际解析由适配器负责;该门禁不声称覆盖仓库范围内的 `process.env` 访问。
|
||||
|
||||
## Consequences
|
||||
|
||||
- Web 凭据表单现在能压过用户 `.env` 里更旧的密钥;只有在启动 shell 里 export 的密钥才会让它变成只读,诊断信息也会这么说。
|
||||
- 含 `DSH_*`、`PATH` 或 proxy 变量的 `.env` 会导致启动失败而不是被应用。把开关放在仓库 `.env` 里的开发者需要改放到 shell——这是一次刻意且响亮的破坏。
|
||||
- composition 不再会被陈旧的 shell endpoint 覆盖。但它仍然会被用户已存的 `settings.yaml` 覆盖,这是 settings seam 的分层方式,本 Note 不改变它;产品 CLI 没有高于它的标志,因此需要压过已存 settings 的部署方要自带 bin 或 loader 配置树。
|
||||
- 未解决的:各层仍然会被物化进 `process.env`,因此普通项目变量继续按子进程清洗规则抵达子进程。bootstrap 变量完全不能来自文件;其余变量抵达子进程的限制记录在环境包中。
|
||||
- Exa 与 Perplexity 仍在加载时捕获密钥,而不是经凭据 seam。它们不再读裸 `process.env`——改为经受信层解析——但把它们改造成按请求经 seam 解析是另一件事。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**按「来源由谁书写」把凭据并入非密钥顺序。** 尝试过并放弃:它读起来很顺,但 settings seam 已经把 composition 固定在用户 section *之下*,因此「部署授权」根本不是该 seam 能表达的一层;而把 `.credentials.yaml` 抬到启动环境之上,会夺走 CI、容器和一次性 `DEEPSEEK_API_KEY=…` 所依赖的那唯一一种覆盖。两条各自说清自身形状成因的顺序,好过一条两边都描述不准的顺序。
|
||||
|
||||
**在项目被显式信任之前,不给它路由与凭据能力。** 作为产品立场被否决:checkout 默认可信,不询问,也不存储信任记录。残留风险是真实的、值得写明——克隆一个携带 `.env`、其中指定了另一个 endpoint 或密钥的仓库,会让该会话经由它——处理它的地方是日后的 project trust 门禁,而不是一条让常见情形都要走仪式的规则。
|
||||
|
||||
**审查出一份 `.env` 可设置的 `DSH_*` 白名单。** 否决:每新增一个开关都要重新审查,而遗漏的失败模式是静默的。拒绝整个命名空间是 fail safe。
|
||||
|
||||
**把 bootstrap 变量排在 process 层之下,而不是拒绝它。** 否决:`PATH` 和 `NODE_OPTIONS` 没有有意义的「输了之后」行为——把它写进 `.env` 的用户认为它生效,而静默忽略正是整个系列要消除的那种「我的设置没有效果」。
|
||||
|
||||
**把快照做成三包能力 seam(`environment` / `environment-local` / 消费方)。** 作为过早拆分而否决:生产方在 Cordis 存在之前就运行,也没有第二个实现需要选择。仓库规则是不要预先拆分。
|
||||
|
||||
**不再把各层物化进 `process.env`。** 延后而非否决:它能让项目变量彻底进不了子进程,但会静默破坏任何读 `!!js process.env.X` 的用户 patch 层。快照已经是 harness 解析一切的依据,因此这件事以后落地也不改变任何 ladder。
|
||||
+6
@@ -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: 4ecbc41adf4e22c74ecf425c2caf628efdf7cf54
|
||||
2026-08-04-credentials-yaml-and-user-environment-layer.zh.md: 370179b442783f4f8ecd8e3badbd236a924f5f81
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
# 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 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](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) 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/.env` is materialized into `process.env`, so it reaches subprocesses under the [subprocess credential scrub](../../../../packages/subprocess/subprocess/README.md) 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 `.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.
|
||||
- The `0600` the 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 the `chmod 600` repair. Windows has no mode to inspect — its ACLs are not expressible here — so the check is skipped rather than faked.
|
||||
- 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.
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
# 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`——一旦提升,每个已存密钥都会读作只读的启动时覆盖,从而阻断从 Models 页轮换密钥。但它的文件名和 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 的,并且只在进程中没有更高层值时物化每个已接受的值,从而得到 `用户 < 项目 < 继承`。Harness home 在两个文件加载*之前*就从继承的环境解析完毕,因此项目 `.env` 无法改变读取哪份用户文档。只有产品 CLI(命令行界面)叠加这两个文件;SDK 与示例 bin 仍通过 `loadEnv` 加载各自的目录,绝不继承开发者的 `$DSH_HOME`。
|
||||
|
||||
凭据优先级会区分继承环境与发现的文件:继承值仍是只读的按次覆盖,其后是受管文档,再后是仍可写的项目与用户 `.env` 后备值。因此 `set` 会替换发现文件中的值,而不是因为扁平化的 `process.env` 视图认为写入会被遮蔽就加以拒绝。
|
||||
|
||||
不做迁移。已经放在 `$DSH_HOME/.env` 里的密钥会继续作为后备值解析;Models 页一旦存储该引用,受管文档就会优先。
|
||||
|
||||
## Consequences
|
||||
|
||||
- 放弃的:留在 `$DSH_HOME/.env` 里的密钥会被物化进 `process.env`,因而会按[子进程凭据清洗](../../../../packages/subprocess/subprocess/README.md)的规则抵达子进程,而不再留在 provider 内部。它仍是 `.credentials.yaml` 之下的可写后备值;需要由 Harness 拥有并隔离的密钥属于受管文档,后者永不物化。
|
||||
- 换来的:用户 `.env` 里的非密钥值终于生效,这正是最初的缺陷;文档格式可以拒绝它无法承担的内容;`0600` 保护的是一个只存密钥的文件,而不是一个我们同时叫用户往里写普通配置的文件。
|
||||
- provider 写入时用的 `0600` 同样约束它读取的内容:在 POSIX 上,只要文档带有任何 group 或 other 权限位,就会在读取内容之前让启动失败——启动时与每次 reload 都检查,诊断里给出 `chmod 600` 的修复命令。Windows 没有可检查的 mode(其 ACL 无法在此表达),因此跳过该检查而不是伪造它。
|
||||
- `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` 层,只保留继承的环境。** 在此处否决为超出范围:它本身是自洽的设计(层次更少、每个值只有一处来源),但会移除用户已有的工作流,而分层问题属于那个被延后的优先级决策,不属于本次拆分。
|
||||
@@ -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/bug-fix/2026-08-06-api-key-format-validation.md
|
||||
2026-08-06-api-key-format-validation.md: d1f6d31d362b76392514704be780f553b45d36ad
|
||||
2026-08-06-api-key-format-validation.zh.md: 75b3fa247bdf449964a874e909e6e3bc9e0694fa
|
||||
2026-08-06-api-key-format-validation.md: e9ca76ede06080f2b868f6436998d163e642adbc
|
||||
2026-08-06-api-key-format-validation.zh.md: 5666a884d4c9478291072375681d8d3526b2632a
|
||||
@@ -12,7 +12,7 @@ Pasting a key containing an emoji, CJK text, or a full-width punctuation mark in
|
||||
|
||||
`llm-pi-ai` was worse on the same input. Its discovery probe builds the same header with a bare `fetch` in [discovery.ts](../../../../packages/llm/llm-pi-ai/src/discovery.ts) and wrapped every failure as `could not reach <url>`, so a local key fault was reported as an unreachable network. The probe is reachable from the unsaved draft: `ProviderEditor` puts the typed `keyDraft` into its probe request, so the model-listing button sent an illegal key before anything was stored.
|
||||
|
||||
Whitespace passed every check. `ProviderEditor` tested `keyDraft.length` and `resolveAdapterOptions` tested `config.apiKey.length`, so a key of three spaces stored and then authenticated as `Bearer` plus blanks. `llm-pi-ai` rejected an empty literal `apiKey` in `resolveProfiles`, but applied no check whatsoever to a credential- or environment-sourced key — the path the Models page writes, and therefore the path users actually take.
|
||||
Whitespace passed every check. `ProviderEditor` tested `keyDraft.length`, so a key of three spaces was stored and then authenticated as `Bearer` plus blanks. Neither adapter checked a credential- or environment-sourced key — the path the Models page writes, and therefore the path users actually take.
|
||||
|
||||
Sources: deepseek-harness#1594 and #1595; dsh-external#247, #249, #266, and #210.
|
||||
|
||||
@@ -32,13 +32,13 @@ The shape rule is a guess about how people paste, so it runs **only in the brows
|
||||
|
||||
### Absence is a configuration state, not a missing key
|
||||
|
||||
"No API key" means three different things here, and only one of them is an error. The rule applies to a value that was *provided*; deciding whether one was provided at all stays with each caller.
|
||||
The rule applies to a value that was *provided*; deciding whether one was provided at all stays with each caller.
|
||||
|
||||
**Omitted.** A profile naming neither `apiKey` nor `apiKeyEnv` is authenticated by something other than a harness-held key. `routeAuth` in [provider.ts](../../../../packages/llm/llm-pi-ai/src/provider.ts) keeps the installed catalog provider's own auth precisely so provider-native ambient discovery survives, and `openai-codex` — shipped in that catalog — authenticates through OAuth and refuses an explicit key outright. `namesCredential` carries this distinction. In `llm-deepseek`, an absent `apiKey` likewise falls through to `apiKeyEnv`. Omission is never validated.
|
||||
**No named credential.** A pi-ai profile omitting `apiKeyEnv` may authenticate outside the harness-held credential path. `routeAuth` in [provider.ts](../../../../packages/llm/llm-pi-ai/src/provider.ts) keeps the installed catalog provider's own auth precisely so provider-native ambient discovery survives, and `openai-codex` — shipped in that catalog — authenticates through OAuth. `namesCredential` carries this distinction; omission is not a value to validate.
|
||||
|
||||
**A blank field in the web UI.** The key input opens empty even for a provider whose key is already stored — the `keyStored` copy reads "Configured — enter a new value to replace" — so blank means *keep what is stored*. `ProviderEditor` skips `credentials.set` entirely when the draft is empty, and that stays a no-op: a blank field never blocks submit, or editing a base URL would demand re-entering the key.
|
||||
|
||||
**Provided, but empty or whitespace-only.** What this means depends on what absence selects for that surface, and the two adapters differ for a reason. In `llm-pi-ai` it is an error, because absence there switches authentication mode — to the installed provider's ambient discovery or OAuth — so a blank key leaves genuine ambiguity about which was meant; its wording names the legitimate alternative rather than just refusing (*has an empty apiKey; omit it to use ambient authentication*). In `llm-deepseek` absence merely selects a different *source* for the same key, `apiKeyEnv`, so a blank literal resolves through that fallback exactly as an omitted one does. In the browser it is always a failure, on both cards: the field is where a person just typed, and silently discarding what they typed is never the right answer.
|
||||
**A resolved value that is whitespace-only.** This is invalid at both adapters because it cannot authenticate a request. In the browser it is also a field-level failure: the field is where a person just typed, and silently discarding what they typed is never the right answer.
|
||||
|
||||
`normalizeApiKey` therefore takes `string`, never `string | undefined`.
|
||||
|
||||
@@ -55,9 +55,7 @@ The client cannot import any of this: client packages reference only client pack
|
||||
| Surface | Behavior |
|
||||
|---|---|
|
||||
| `dsh-llm` | Owns `normalizeApiKey`, `assertUsableApiKey`, and `INVALID_CREDENTIAL_CODE`, which is deliberately outside `DEFAULT_RETRYABLE_CODES`. |
|
||||
| `llm-deepseek` `resolveAdapterOptions` | Refuses a literal `apiKey` no header can carry, beside the other beyond-schema bounds; uses the trimmed value. An absent or blank one falls through to `apiKeyEnv`. |
|
||||
| `llm-deepseek` `resolveApiKey` | Normalizes what the credentials seam or environment returns, rejecting with `INVALID_CREDENTIAL` naming the Models page and never echoing the key. |
|
||||
| `llm-pi-ai` `resolveProfiles` | Applies the shared rule, keeping its "omit it to use ambient authentication" wording, and writes the trimmed value into the resolved profile. |
|
||||
| `llm-pi-ai` `resolveApiKey` | Normalizes the credential and environment paths. A profile naming no credential still returns `undefined`, so ambient and OAuth routes are unaffected. |
|
||||
| `llm-pi-ai` `discoverModels` | Normalizes before building the header, so an illegal key is a credential fault rather than an unreachable endpoint. A probe carrying no key stays unauthenticated. |
|
||||
| `ui-models` | Mirrors the charset rule, adds the shape heuristic, trims `keyDraft` before probe and `credentials.set`, and fixes the `stringAt` emptiness test. A blank field remains a no-op that submits; a field holding only whitespace is a field-level failure. Submit **and the endpoint interrogation** are both gated, so a refused key never spends a round trip to be told what the field already says, and the failure renders on the field, matching the existing `modelFailure` pattern. |
|
||||
@@ -68,8 +66,6 @@ The client cannot import any of this: client packages reference only client pack
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**A `.pattern()` on the `apiKey` schema field.** Vendored schemastery supports it, and the pattern would serialize to the browser with the rest of the namespace schema — one rule, delivered rather than mirrored. It lost because a pattern cannot trim first: `cordis.yml` would then reject a padded key while `.env` tolerated one, and the resolver would disagree with the schema about the same string. Validating in `resolveAdapterOptions` keeps every surface trim-then-validate, and that function is already where this package re-judges bounds the schema cannot express.
|
||||
|
||||
**A validation module shared by client and host.** Rejected by the source-plane layout: client packages reference only client packages plus `vendor/cordis` and `support/invariants`, and widening that to reach a host package would collide the two `Context` merges the split exists to keep apart. Mirroring a one-line predicate with a test on each side is the established shape here.
|
||||
|
||||
**A per-adapter thrower in each of `llm-deepseek` and `llm-pi-ai`.** The first plan gave each adapter its own, differing only by the package prefix in the message, with a duplication-gate exemption to excuse the pair. Rejected before implementation: `LlmError` is declared in the seam, so the seam can own the diagnosis outright, and an exemption there would have hidden exactly the duplication it was covering for.
|
||||
@@ -100,7 +96,7 @@ The costliest way to get this wrong would have been to treat absence as invalidi
|
||||
|
||||
`packages/llm/llm/tests/api-key.spec.ts` drives `normalizeApiKey` and `assertUsableApiKey` over the whole input table — empty, whitespace-only, padded, interior-space, C0 control, emoji, CJK, full-width, latin-1, and the printable-ASCII boundary — and pins that a refusal carries `INVALID_CREDENTIAL` and no part of the key.
|
||||
|
||||
`packages/llm/llm-deepseek/tests/` covers the literal-config path in `adapter.spec.ts` and the stored-credential path end to end in `dynamic-config.spec.ts`, through the real credentials seam rather than a stub. `packages/llm/llm-pi-ai/tests/` covers `resolveProfiles` — including that the trimmed value reaches the resolved profile, which the `...rest` spread would otherwise discard — and the discovery probe, including that a probe with no key sends no `authorization` header.
|
||||
`packages/llm/llm-deepseek/tests/` covers the stored-credential path end to end in `dynamic-config.spec.ts`, through the real credentials seam rather than a stub. `packages/llm/llm-pi-ai/tests/` covers the discovery probe, including that a probe with no key sends no `authorization` header.
|
||||
|
||||
`packages/client/ui-models/tests/` pins `apiKeyFailure` over the same table plus the paste-shape cases, and drives both cards: a blank field submits without writing a credential, a whitespace-only field fails on the field, an illegal or wrapped key blocks submit and the interrogation alike, a padded key is trimmed before `credentials.set` and before an interrogation, and a hand-declared route can be created with no key at all.
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Status: implemented
|
||||
|
||||
同样的输入在 `llm-pi-ai` 上更糟。它的探测路径在 [discovery.ts](../../../../packages/llm/llm-pi-ai/src/discovery.ts) 里用裸 `fetch` 构造同一个 header,并把一切失败包装成 `could not reach <url>`,于是一个本地的 Key 故障被报成网络不可达。这条探测在保存之前就够得着:`ProviderEditor` 把用户输入的 `keyDraft` 直接放进探测请求,所以「获取模型列表」按钮会在任何东西落盘之前就把非法 Key 发出去。
|
||||
|
||||
空白字符能通过每一道检查。`ProviderEditor` 判的是 `keyDraft.length`,`resolveAdapterOptions` 判的是 `config.apiKey.length`,于是三个空格构成的 Key 会被存下,随后以 `Bearer` 加若干空格去认证。`llm-pi-ai` 在 `resolveProfiles` 中拒绝空的字面量 `apiKey`,却对来自凭据或环境的 Key 完全不做检查——而那正是模型设置页写入的路径,也就是用户真正走的路径。
|
||||
空白字符能通过每一道检查。`ProviderEditor` 判的是 `keyDraft.length`,于是三个空格构成的 Key 会被存下,随后以 `Bearer` 加若干空格去认证。两个适配器都不检查来自凭据或环境的 Key——而那正是 Models 页写入的路径,也就是用户真正走的路径。
|
||||
|
||||
来源:deepseek-harness#1594 与 #1595;dsh-external#247、#249、#266、#210。
|
||||
|
||||
@@ -32,13 +32,13 @@ Status: implemented
|
||||
|
||||
### 「没有 Key」是一种配置状态,不是缺失
|
||||
|
||||
在这里,「没有 API Key」意味着三件完全不同的事,其中只有一件是错误。规则作用于**已提供**的值;至于究竟有没有提供,由各个调用方自行判断。
|
||||
规则作用于*已提供*的值;至于究竟有没有提供,由各个调用方自行判断。
|
||||
|
||||
**未指定。** 既不写 `apiKey` 也不写 `apiKeyEnv` 的 profile,是由 harness 所持有的 Key 之外的东西来鉴权的。[provider.ts](../../../../packages/llm/llm-pi-ai/src/provider.ts) 中的 `routeAuth` 保留内置 catalog provider 自身的鉴权,正是为了让 provider 原生的 ambient 发现得以存活;而该 catalog 附带的 `openai-codex` 通过 OAuth 鉴权,并会直接拒绝一个显式的 Key。`namesCredential` 承载着这一区分。在 `llm-deepseek` 中,缺省的 `apiKey` 同样会回落到 `apiKeyEnv`。未指定的情形永不参与校验。
|
||||
**未点名凭据。** 省略 `apiKeyEnv` 的 pi-ai profile 可以在 harness 持有的凭据路径之外鉴权。[provider.ts](../../../../packages/llm/llm-pi-ai/src/provider.ts) 中的 `routeAuth` 保留内置 catalog provider 自身的鉴权,正是为了让 provider 原生的 ambient 发现继续工作;而该 catalog 附带的 `openai-codex` 通过 OAuth 鉴权。`namesCredential` 承载这一区分;省略不是需要校验的值。
|
||||
|
||||
**Web UI 中留空的输入框。** 即便某个 provider 的 Key 已经存好,该输入框也是空着打开的——`keyStored` 的文案写的是「已配置——输入新值以替换」——所以留空意味着*保持已存储的值*。`ProviderEditor` 在草稿为空时完全跳过 `credentials.set`,这一点保持不变:留空绝不拦截提交,否则改一个 base URL 都得重新输一遍 Key。
|
||||
|
||||
**已提供,但为空或纯空白。** 它意味着什么,取决于「缺失」在该界面上选中了什么,而两个适配器的差异是有依据的。在 `llm-pi-ai` 中它是错误,因为那里的缺失切换的是**鉴权方式**——转向内置 provider 的 ambient 发现或 OAuth——因此一个空 Key 究竟想选哪一种是真有歧义;它的措辞指明了合法替代路径而非单纯拒绝(*has an empty apiKey; omit it to use ambient authentication*)。在 `llm-deepseek` 中,缺失只是为同一把 Key 选择了另一个**来源** `apiKeyEnv`,因此空白字面量会像缺省一样经该回落解析。在浏览器中它始终是失败,两张卡片皆然:字段是人刚刚敲过字的地方,静默丢弃他敲进去的内容永远不是正确答案。
|
||||
**解析得到的值只含空白。** 两个适配器都将其视为非法,因为它无法为请求鉴权。在浏览器中,这同样是字段级失败:字段是人刚刚敲过字的地方,静默丢弃他敲进去的内容永远不是正确答案。
|
||||
|
||||
因此 `normalizeApiKey` 接受 `string`,而绝非 `string | undefined`。
|
||||
|
||||
@@ -55,9 +55,7 @@ Status: implemented
|
||||
| 界面 | 行为 |
|
||||
|---|---|
|
||||
| `dsh-llm` | 拥有 `normalizeApiKey`、`assertUsableApiKey` 与 `INVALID_CREDENTIAL_CODE`,后者刻意不进 `DEFAULT_RETRYABLE_CODES`。 |
|
||||
| `llm-deepseek` `resolveAdapterOptions` | 拒绝标头无法承载的字面量 `apiKey`,与其他超出 schema 的边界检查并排;使用 trim 后的值。缺省或空白的 `apiKey` 回落到 `apiKeyEnv`。 |
|
||||
| `llm-deepseek` `resolveApiKey` | 归一化凭据 seam 或环境返回的值,以 `INVALID_CREDENTIAL` 拒绝,消息指明模型设置页,绝不回显 Key。 |
|
||||
| `llm-pi-ai` `resolveProfiles` | 施加这条共享规则,保留其「omit it to use ambient authentication」的措辞,并把 trim 后的值写进解析后的 profile。 |
|
||||
| `llm-pi-ai` `resolveApiKey` | 归一化凭据与环境路径。不指定任何凭据的 profile 仍返回 `undefined`,ambient 与 OAuth 路由不受影响。 |
|
||||
| `llm-pi-ai` `discoverModels` | 在构造 header 之前归一化,使非法 Key 成为凭据故障而非端点不可达。不带 Key 的探测保持未鉴权。 |
|
||||
| `ui-models` | 镜像字符集规则,加入形状启发式,在探测与 `credentials.set` 之前 trim `keyDraft`,并修正 `stringAt` 的空值判断。留空的输入框仍是可以提交的空操作;只含空白的输入框则是字段级失败。提交**与端点探测**同时受拦截,因此被拒绝的密钥不会白花一次往返去换取字段上已经写明的答案;失败呈现在字段上,与既有的 `modelFailure` 模式一致。 |
|
||||
@@ -68,8 +66,6 @@ Status: implemented
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**在 `apiKey` schema 字段上加 `.pattern()`。** vendor 中的 schemastery 支持它,且该 pattern 会随命名空间 schema 一同序列化到浏览器——一条规则,投递而非镜像。它落败于 pattern 无法先行 trim:那样 `cordis.yml` 会拒绝带首尾空白的 Key 而 `.env` 却容忍,resolver 与 schema 会对同一个字符串给出分歧。在 `resolveAdapterOptions` 中校验可以让每一层都是 trim-then-validate,而该函数本就是本包重新裁定 schema 无法表达的边界之处。
|
||||
|
||||
**由 client 与 host 共享一个校验模块。** 被 source plane 布局否决:client 包只 reference client 包外加 `vendor/cordis` 与 `support/invariants`,把它放宽到够得着 host 包会撞上这一分割本就要隔开的两份 `Context` 合并。在两侧各镜像一行断言并各配一份测试,是此处的既定形态。
|
||||
|
||||
**在 `llm-deepseek` 与 `llm-pi-ai` 中各留一个抛错 helper。** 最初的计划正是各留一份,差别仅在消息中的包名前缀,并配一个重复检测豁免来放行这一对。在实现之前即被否决:`LlmError` 声明在 seam 中,因此 seam 完全可以自己拥有这句诊断,而那里的一个豁免恰恰会掩盖它本要遮掩的重复。
|
||||
@@ -100,7 +96,7 @@ Status: implemented
|
||||
|
||||
`packages/llm/llm/tests/api-key.spec.ts` 以整张输入表驱动 `normalizeApiKey` 与 `assertUsableApiKey`——空值、纯空白、带首尾空白、含中间空格、C0 控制字符、emoji、中文、全角、latin-1,以及可打印 ASCII 的边界字符——并钉住一次拒绝携带 `INVALID_CREDENTIAL` 且不含 Key 的任何部分。
|
||||
|
||||
`packages/llm/llm-deepseek/tests/` 在 `adapter.spec.ts` 中覆盖字面量配置路径,在 `dynamic-config.spec.ts` 中经真实凭据 seam(而非 stub)端到端覆盖已存储凭据路径。`packages/llm/llm-pi-ai/tests/` 覆盖 `resolveProfiles`——包括 trim 后的值确实到达解析后的 profile,否则会被 `...rest` 展开丢弃——以及探测路径,包括不带 Key 的探测不会发出 `authorization` 标头。
|
||||
`packages/llm/llm-deepseek/tests/` 在 `dynamic-config.spec.ts` 中经真实凭据 seam(而非 stub)端到端覆盖已存储凭据路径。`packages/llm/llm-pi-ai/tests/` 覆盖探测路径,包括不带 Key 的探测不会发出 `authorization` 标头。
|
||||
|
||||
`packages/client/ui-models/tests/` 以同一张表加上形状用例钉住 `apiKeyFailure`,并驱动两张卡片:留空的输入框可提交且不写入凭据、只含空白的输入框在字段上失败、非法或被包裹的 Key 同时拦截提交与探测、带首尾空白的 Key 在 `credentials.set` 与探测之前被 trim,以及手工声明的路由可以完全不带 Key 创建。
|
||||
|
||||
|
||||
+2
-2
@@ -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/bug-fix/2026-08-06-provider-credential-lifecycle.md
|
||||
2026-08-06-provider-credential-lifecycle.md: ce45207e7ac7224f44e34945e36ba85db0971f09
|
||||
2026-08-06-provider-credential-lifecycle.zh.md: c476417517b8ed72036344a13720a8ba378775e6
|
||||
2026-08-06-provider-credential-lifecycle.md: c28788921e8f1b233b44e19b29ad4d4acaa25022
|
||||
2026-08-06-provider-credential-lifecycle.zh.md: 2ea3b21fb6ceb4fa38a0cad0daf47c3b6a98a664
|
||||
@@ -12,7 +12,7 @@ The Models editor spans independent settings and credential RPC domains. It prev
|
||||
|
||||
Provider save remains a two-stage settings-then-credentials operation over the existing wire domains, but the card treats the successful settings response as a commit checkpoint. It replaces its comparison subtree and expected revision with the returned redacted descriptor before attempting `credentials.set`; if that second stage fails, the draft key and card stay visible, and retry produces no settings ops and repeats only the credential write. Genuine concurrent changes before the first settings commit still fail with `settings-conflict`. Typed keys are trimmed at the UI and direct DeepSeek resolver boundaries, and pi-ai records a derived reference only when the normalized key is non-empty; saving a blank key materializes an empty, reference-free profile for provider-native discovery.
|
||||
|
||||
Deletion removes a credential only when the joined row identifies the exact `<ROUTE>_API_KEY` reference derived by this page and reports it configured and writable. It unsets that credential before the user-layer profile so a settings-stage failure leaves the row and its frozen target visible for retry; both unsets are idempotent. Custom references, environment credentials, missing credentials, and targets the join cannot identify are retained. The row's accessible Edit/Delete names and the destructive dialog title, description, and final action all use the same stable `Display Name (route-id)` identity, collapsing to the route id when both strings match. The dialog states whether the stored key will be removed and owns operation failures instead of replacing the whole page with a load-error banner. Rows expose API-key state only from the value-free join: a confirmed literal or referenced credential is a green solid dot, a confirmed missing named reference is a red solid dot, and reference-free provider-native authentication or unavailable credential enrichment has no dot. Each dot has accessible copy and a tooltip, while successful Apply uses the same provider identity in a local status message and never echoes secret material.
|
||||
Deletion removes a credential only when the joined row identifies the exact `<ROUTE>_API_KEY` reference derived by this page and reports it configured and writable. It unsets that credential before the user-layer profile so a settings-stage failure leaves the row and its frozen target visible for retry; both unsets are idempotent. Custom references, environment credentials, missing credentials, and targets the join cannot identify are retained. The row's accessible Edit/Delete names and the destructive dialog title, description, and final action all use the same stable `Display Name (route-id)` identity, collapsing to the route id when both strings match. The dialog states whether the stored key will be removed and owns operation failures instead of replacing the whole page with a load-error banner. Rows expose API-key state only from the value-free join: a confirmed referenced credential is a green solid dot, a confirmed missing named reference is a red solid dot, and reference-free provider-native authentication or unavailable credential enrichment has no dot. Each dot has accessible copy and a tooltip, while successful Apply uses the same provider identity in a local status message and never echoes secret material.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -20,8 +20,8 @@ Deletion removes a credential only when the joined row identifies the exact `<RO
|
||||
|
||||
**Delete every credential reference named by a removed profile.** A custom reference can be shared, externally managed, or intentionally survive profile churn. Exact equality with this page's derived target plus configured+writable state is the narrow evidence available to the page; anything weaker risks deleting a credential it does not own.
|
||||
|
||||
**Remove settings first and compensate by recreating the profile.** The browser holds only a redacted subtree and cannot faithfully reconstruct stored literal secrets or concurrent edits. Credential-first deletion leaves the authoritative profile visible on partial failure and makes retry safe without synthesizing configuration.
|
||||
**Remove settings first and compensate by recreating the profile.** The browser holds only a redacted subtree and cannot faithfully reconstruct concurrent edits. Credential-first deletion leaves the authoritative profile visible on partial failure and makes retry safe without synthesizing configuration.
|
||||
|
||||
## Consequences
|
||||
|
||||
The Models page can recover from either second-stage failure without reload, secret disclosure, or a false concurrency conflict, and blank-key pi-ai profiles preserve Bedrock, Vertex, and other provider-native authentication. Confirmed status is visible without turning route liveness, native authentication, or a failed credential lookup into a false error, and a successful replacement remains observable even when the row stays green. Deleting a page-managed provider no longer leaves a reusable local key, while ambiguous credentials deliberately remain for manual management. Save and delete are still not atomic across durable stores: a process crash can stop between stages, but their order and idempotence leave an observable, retryable state. Component tests pin partial-success retries, empty-key native auth, normalized literals, status visibility, target identity, cleanup ownership, and credential/settings rejection ordering; the keyless browser scenario pins bilingual accessible copy and verifies that confirmed deletion removes both `settings.yaml` profile and `.env` credential. This decision refines the Models apply semantics recorded in the [web configuration plane note](../architecture/2026-07-30-web-config-plane.md).
|
||||
The Models page can recover from either second-stage failure without reload, secret disclosure, or a false concurrency conflict, and blank-key pi-ai profiles preserve Bedrock, Vertex, and other provider-native authentication. Confirmed status is visible without turning route liveness, native authentication, or a failed credential lookup into a false error, and a successful replacement remains observable even when the row stays green. Deleting a page-managed provider no longer leaves a reusable local key, while ambiguous credentials deliberately remain for manual management. Save and delete are still not atomic across durable stores: a process crash can stop between stages, but their order and idempotence leave an observable, retryable state. Component tests pin partial-success retries, empty-key native auth, trimmed key handling, status visibility, target identity, cleanup ownership, and credential/settings rejection ordering; the keyless browser scenario pins bilingual accessible copy and verifies that confirmed deletion removes both the `settings.yaml` profile and `.credentials.yaml` entry. This decision refines the Models apply semantics recorded in the [web configuration plane note](../architecture/2026-07-30-web-config-plane.md).
|
||||
@@ -12,7 +12,7 @@ Models 编辑器横跨互相独立的 settings 与凭据 RPC 领域。之前它
|
||||
|
||||
提供方保存仍在现有 wire 领域上按先 settings、后凭据的两阶段顺序执行,但卡片会把成功的 settings 响应视为提交检查点。它会在尝试 `credentials.set` 之前,用返回的脱敏 descriptor 替换比较基准子树与预期 revision;如果第二阶段失败,草稿密钥与卡片会继续显示,重试不会产生 settings op,只会再次写入凭据。首次 settings 提交之前发生的真实并发变更仍会以 `settings-conflict` 失败。UI 与 DeepSeek 直连 resolver 边界均会去除所输密钥的首尾空白,且只有标准化密钥非空时,pi-ai 才会记录派生引用;留空密钥会具化一个空的、不带引用的 profile,以便使用提供方原生凭据发现。
|
||||
|
||||
只有当联接所得的行识别出该页面派生的精确 `<ROUTE>_API_KEY` 引用,并将其报告为已配置且可写时,删除操作才会清除该凭据。它会先取消设置该凭据,再取消设置用户层 profile;如果 settings 阶段失败,该行及其已冻结的目标仍可见,便于重试。两项 unset 都具备幂等性。自定义引用、环境凭据、缺失的凭据,以及联接无法识别目标的凭据均会保留。行的无障碍 Edit/Delete 名称以及破坏性对话框的标题、说明和最终操作都使用同一个稳定的 `Display Name (route-id)` 标识;当两个字符串相同时,标识会简化为路由 id。对话框会说明是否一并删除已存密钥,并在自身内显示操作失败,而不是用加载错误横幅替换整个页面。行只根据不含值的联接结果展示 API 密钥状态:确认已配置的字面密钥或引用凭据显示为绿色实心点,确认缺失的具名引用显示为红色实心点,无引用的提供方原生认证或无法取得凭据补充信息时则不显示状态点。每个状态点都有无障碍文案和工具提示;「应用」成功后的本地状态消息会使用同一个提供方标识,且绝不回显任何机密内容。
|
||||
只有当联接所得的行识别出该页面派生的精确 `<ROUTE>_API_KEY` 引用,并将其报告为已配置且可写时,删除操作才会清除该凭据。它会先取消设置该凭据,再取消设置用户层 profile;如果 settings 阶段失败,该行及其已冻结的目标仍可见,便于重试。两项 unset 都具备幂等性。自定义引用、环境凭据、缺失的凭据,以及联接无法识别目标的凭据均会保留。行的无障碍 Edit/Delete 名称以及破坏性对话框的标题、说明和最终操作都使用同一个稳定的 `Display Name (route-id)` 标识;当两个字符串相同时,标识会简化为路由 id。对话框会说明是否一并删除已存密钥,并在自身内显示操作失败,而不是用加载错误横幅替换整个页面。行只根据不含值的联接结果展示 API 密钥状态:确认已配置的引用凭据显示为绿色实心点,确认缺失的具名引用显示为红色实心点,无引用的提供方原生认证或无法取得凭据补充信息时则不显示状态点。每个状态点都有无障碍文案和工具提示;「应用」成功后的本地状态消息会使用同一个提供方标识,且绝不回显任何机密内容。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
@@ -20,8 +20,8 @@ Models 编辑器横跨互相独立的 settings 与凭据 RPC 领域。之前它
|
||||
|
||||
**删除被移除 profile 所指定的每一个凭据引用。**自定义引用可能被共享、由外部管理,或有意在 profile 反复增删时存留。与该页面派生目标精确相等,再加上已配置且可写的状态,是页面所能获得的最小范围证据;比这更弱的判定都有可能删除不属于它的凭据。
|
||||
|
||||
**先删除 settings,再重建 profile 以作补偿。**浏览器只持有脱敏后的子树,无法忠实重建已存的字面机密或并发编辑。先删除凭据可以让权威 profile 在部分失败时仍然可见,并且无需合成配置就能安全重试。
|
||||
**先删除 settings,再重建 profile 以作补偿。**浏览器只持有脱敏后的子树,无法忠实重建并发编辑。先删除凭据可以让权威 profile 在部分失败时仍然可见,并且无需合成配置就能安全重试。
|
||||
|
||||
## 后果
|
||||
|
||||
Models 页可以从任一第二阶段失败中恢复,无需重新加载,也不会泄露机密或产生虚假的并发冲突;空密钥的 pi-ai profile 会保留 Bedrock、Vertex 与其他提供方原生认证。已确认的状态清晰可见,同时不会把路由存活状态、原生认证或凭据查询失败误报为错误;即使该行继续显示绿色,密钥替换成功也仍然可观察。删除由页面管理的提供方不再遗留可重用的本地密钥,而存在歧义的凭据会有意保留,交由手动管理。保存与删除在跨持久存储时仍非原子操作:进程可能在两个阶段之间崩溃,但它们的顺序与幂等性会留下可观察、可重试的状态。组件测试固定了部分成功后的重试、空密钥原生认证、标准化字面值、状态可见性、目标标识、清理所有权,以及凭据/settings 拒绝顺序;无密钥的浏览器场景固定了双语无障碍文案,并验证确认删除会同时清除 `settings.yaml` profile 与 `.env` 凭据。此决策细化了 [web 配置平面 note](../architecture/2026-07-30-web-config-plane.md) 中记录的 Models 应用语义。
|
||||
Models 页可以从任一第二阶段失败中恢复,无需重新加载,也不会泄露机密或产生虚假的并发冲突;空密钥的 pi-ai profile 会保留 Bedrock、Vertex 与其他提供方原生认证。已确认的状态清晰可见,同时不会把路由存活状态、原生认证或凭据查询失败误报为错误;即使该行继续显示绿色,密钥替换成功也仍然可观察。删除由页面管理的提供方不再遗留可重用的本地密钥,而存在歧义的凭据会有意保留,交由手动管理。保存与删除在跨持久存储时仍非原子操作:进程可能在两个阶段之间崩溃,但它们的顺序与幂等性会留下可观察、可重试的状态。组件测试固定了部分成功后的重试、空密钥原生认证、密钥首尾空白处理、状态可见性、目标标识、清理所有权,以及凭据/settings 拒绝顺序;无密钥的浏览器场景固定了双语无障碍文案,并验证确认删除会同时清除 `settings.yaml` profile 与 `.credentials.yaml` 条目。此决策细化了 [web 配置平面 note](../architecture/2026-07-30-web-config-plane.md) 中记录的 Models 应用语义。
|
||||
+2
-2
@@ -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/feature/2026-07-30-deepseek-onboarding-credential-setup.md
|
||||
2026-07-30-deepseek-onboarding-credential-setup.md: c732758bc567376a0be4ac348aa9129aa8126ac4
|
||||
2026-07-30-deepseek-onboarding-credential-setup.zh.md: 2dc7e0ccf5f9a99ad35c859a7ecfb9f98d93d530
|
||||
2026-07-30-deepseek-onboarding-credential-setup.md: 419ea0aea56e82e301189d90d5ca78495da2da71
|
||||
2026-07-30-deepseek-onboarding-credential-setup.zh.md: 936402c9ed83eccc3d5e78a57247f06347522c0f
|
||||
+2
-2
@@ -10,7 +10,7 @@ The [web configuration plane](../architecture/2026-07-30-web-config-plane.md) ma
|
||||
|
||||
## Decision
|
||||
|
||||
**One readiness projection owns both Models and onboarding facts.** `ui-models` keeps a single store that joins `llm.providers({})`, redacted `settings.describe({})`, and batched `credentials.describe({refs})`. The onboarding projection selects the `deepseek-official` configurable-provider entry owned by the `llm-deepseek` namespace and empty settings path, reads the effective `apiKeyEnv`, and evaluates the matching credential descriptor. A live route with the same provider id but no matching configurable-provider declaration is adapter-absent for onboarding. A configured literal `apiKey` secret sidecar is also ready, so compatibility configuration does not trigger a false prompt; a configured process-environment credential is ready and remains read-only.
|
||||
**One readiness projection owns both Models and onboarding facts.** `ui-models` keeps a single store that joins `llm.providers({})`, redacted `settings.describe({})`, and batched `credentials.describe({refs})`. The onboarding projection selects the `deepseek-official` configurable-provider entry owned by the `llm-deepseek` namespace and empty settings path, reads the effective `apiKeyEnv`, and evaluates the matching credential descriptor. A live route with the same provider id but no matching configurable-provider declaration is adapter-absent for onboarding. A configured process-environment credential is ready and remains read-only.
|
||||
|
||||
**The settings shell contributes ordering and navigation, not provider policy.** `ui-settings` declares a root-scoped `settings.onboarding` list slot and mounts one ordered step at a time while the current surface is the empty Hero. The active registrant receives `complete()` and a private `openSection(id)` callback; completion transfers ownership to the next entry. `ui-models` registers the DeepSeek step and its Models section through `slots.inject()`, so each contribution follows its declaration lifetime without making plugin load order a contract, and independently contributed dialogs cannot stack. The product-wide welcome step that precedes it is owned separately by [the versioned welcome decision](2026-07-30-versioned-gui-welcome-onboarding.md).
|
||||
|
||||
@@ -30,4 +30,4 @@ The [web configuration plane](../architecture/2026-07-30-web-config-plane.md) ma
|
||||
|
||||
## Consequences
|
||||
|
||||
The ordered flow leads from the product notice to the shipped adapter's existing editor without restarting: a keyless browser test boots the real Web composition under an isolated harness home, acknowledges the notice, follows the DeepSeek page to Models, stores a generated key through that page into the home's `.env`, verifies no key reaches DOM, ARIA, or browser console output, and confirms the running page reports configured. The full keyless Web replay lane also pins that a non-configurable replay route with the same provider id does not block unrelated journeys. Pure readiness and React tests pin literal, file, process-environment, missing-provider, missing-capability, navigation, cancellation, external-invalidation, and coordinator-transfer behavior. The flow deliberately inherits the configuration plane's documented base limitations rather than adding local secret storage, redaction, or settings replacement workarounds.
|
||||
The ordered flow leads from the product notice to the shipped adapter's existing editor without restarting: a keyless browser test boots the real Web composition under an isolated harness home, acknowledges the notice, follows the DeepSeek page to Models, stores a generated key through that page into the home's `.credentials.yaml`, verifies no key reaches DOM, ARIA, or browser console output, and confirms the running page reports configured. The full keyless Web replay lane also pins that a non-configurable replay route with the same provider id does not block unrelated journeys. Pure readiness and React tests pin managed-file and process-environment credentials, missing providers and capabilities, navigation, cancellation, external invalidation, and coordinator transfer. The flow deliberately inherits the configuration plane's documented base limitations rather than adding local secret storage, redaction, or settings replacement workarounds.
|
||||
+2
-2
@@ -10,7 +10,7 @@ Status: implemented
|
||||
|
||||
## 决策
|
||||
|
||||
**Models 与首次使用引导共享同一个就绪状态投影。**`ui-models` 维护一个 store,把 `llm.providers({})`、脱敏后的 `settings.describe({})` 和批量调用的 `credentials.describe({refs})` 联接为同一份状态。首次使用投影选取由 `llm-deepseek` namespace 与空 settings path 持有的 `deepseek-official` 可配置提供方条目,读取生效的 `apiKeyEnv`,并检查对应的凭据描述符。同 provider id 但没有匹配可配置提供方声明的存活路由,在首次使用引导中视为适配器缺失。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,也会判定为就绪,兼容配置因此不会误触发页面;通过进程环境提供的凭据若已配置,同样判定为就绪并保持只读。
|
||||
**Models 与首次使用引导共享同一个就绪状态投影。**`ui-models` 维护一个 store,把 `llm.providers({})`、脱敏后的 `settings.describe({})` 和批量调用的 `credentials.describe({refs})` 联接为同一份状态。首次使用投影选取由 `llm-deepseek` namespace 与空 settings path 持有的 `deepseek-official` 可配置提供方条目,读取生效的 `apiKeyEnv`,并检查对应的凭据描述符。同 provider id 但没有匹配可配置提供方声明的存活路由,在首次使用引导中视为适配器缺失。通过进程环境提供的凭据若已配置,则判定为就绪并保持只读。
|
||||
|
||||
**设置外壳只贡献排序与导航,不持有提供方策略。** `ui-settings` 声明一个根作用域的 `settings.onboarding` list slot,并在当前界面为空白 Hero 时,每次只挂载一个有序步骤。当前注册方会收到 `complete()` 和私有 `openSection(id)` 回调;完成当前步骤后,所有权转交给下一项。`ui-models` 通过 `slots.inject()` 注册 DeepSeek 步骤及其 Models 分区,使每项贡献都跟随自身的声明生命周期,不让插件加载顺序成为契约;独立贡献的对话框也无法堆叠。排在它之前的产品级欢迎步骤由[版本化欢迎决策](2026-07-30-versioned-gui-welcome-onboarding.md)单独持有。
|
||||
|
||||
@@ -30,4 +30,4 @@ Status: implemented
|
||||
|
||||
## 后果
|
||||
|
||||
有序流程从产品声明页开始,无需重启即可引导用户前往随产品提供的适配器已有的编辑器:无密钥浏览器测试在隔离的 harness 家目录下启动真实 Web 组合,确认声明后依照 DeepSeek 页面前往 Models,通过该页面把生成的密钥存入该目录的 `.env`,验证密钥未进入 DOM、ARIA 或浏览器控制台输出,并确认运行中的页面报告已配置。完整的无密钥 Web 回放也固定了同 id 的不可配置回放路由不会阻塞无关流程。纯就绪状态测试与 React 测试固化了字面量凭据、文件凭据、进程环境凭据、提供方缺失、能力缺失、导航、取消、外部失效和协调器移交行为。该流程直接继承配置平面已记录的基础限制,不会另加局部的机密存储、脱敏或设置替换变通方案。
|
||||
有序流程从产品声明页开始,无需重启即可引导用户前往随产品提供的适配器已有的编辑器:无密钥浏览器测试在隔离的 harness 家目录下启动真实 Web 组合,确认声明后依照 DeepSeek 页面前往 Models,通过该页面把生成的密钥存入该目录的 `.credentials.yaml`,验证密钥未进入 DOM、ARIA 或浏览器控制台输出,并确认运行中的页面报告已配置。完整的无密钥 Web 回放也固定了同 id 的不可配置回放路由不会阻塞无关流程。纯就绪状态测试与 React 测试固化了受管文件凭据与进程环境凭据、提供方与能力缺失、导航、取消、外部失效和协调器移交。该流程直接继承配置平面已记录的基础限制,不会另加局部的机密存储、脱敏或设置替换变通方案。
|
||||
@@ -57,7 +57,6 @@ External packages that a workspace package resolves at runtime. `scripts/install
|
||||
| [`clsx`](https://github.com/lukeed/clsx) | MIT |
|
||||
| [`commander`](https://github.com/tj/commander.js) | MIT |
|
||||
| [`diff`](https://github.com/kpdecker/jsdiff) | BSD-3-Clause |
|
||||
| [`dotenv`](https://github.com/motdotla/dotenv) | BSD-2-Clause |
|
||||
| [`eventsource-parser`](https://github.com/rexxars/eventsource-parser) | MIT |
|
||||
| [`handlebars`](https://github.com/handlebars-lang/handlebars.js) | MIT |
|
||||
| [`immer`](https://github.com/immerjs/immer) | MIT |
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
"@deepseek-ai/dsh-frontend-static": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-webserver": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-mock-server": "workspace:^",
|
||||
"@deepseek-ai/dsh-loader-smoke": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
|
||||
@@ -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 apps/cli/reference/README.md
|
||||
README.md: 8b8a0e7dbebafedd6a4f8d988adb3fd11c7bd026
|
||||
README.zh.md: d1d6d5a594596a8be5db30021163f0fcea4a95bf
|
||||
README.md: c7c7b2aa231d4c9f4b3fbf31663237c8457eb051
|
||||
README.zh.md: 5439aa78b74415c8e6264d21f5c52e5cee5b38ee
|
||||
@@ -59,7 +59,7 @@ New sessions default to the `workspace-write` permission preset. Bash and filesy
|
||||
|
||||
## Shared deployment behavior
|
||||
|
||||
The base bundle mounts the native DeepSeek adapter, settings and credential providers, stable `web_search`, repository Plugin support, and session telemetry. Provider credentials live in `$DSH_HOME/.env` or the ambient environment and remain rotatable because the launcher never hoists the credential file into `process.env`. Search uses `DEEPSEEK_API_KEY` and accepts `DEEPSEEK_SEARCH_BASE_URL`; `web_fetch` is disabled unless a patch layer inserts a provider and enables it.
|
||||
The base bundle mounts the native DeepSeek adapter, settings and credential providers, stable `web_search`, repository Plugin support, and session telemetry. Provider credentials resolve from the inherited environment, `$DSH_HOME/.credentials.yaml`, the invoking directory's `.env`, then `$DSH_HOME/.env`; the managed document is never materialized into `process.env`, while both `.env` files are ordinary launch environment layers. Search uses `DEEPSEEK_API_KEY` and accepts `DEEPSEEK_SEARCH_BASE_URL`; `web_fetch` is disabled unless a patch layer inserts a provider and enables it.
|
||||
|
||||
Session events stream as OTLP/HTTP logs by default. `DSH_TELEMETRY_OTLP_URL` selects another collector. Any non-empty `DSH_TELEMETRY_DISABLED` disables the telemetry row before boot. The shipped base has no telemetry redaction rule, so exported records can contain message text, tool arguments and results, and workspace paths; the [telemetry Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md) owns that deployment decision.
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ dsh web --dump-config
|
||||
|
||||
## 共享部署行为
|
||||
|
||||
基础组合包挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search`、repository Plugin 支持和会话遥测。提供方凭据存放在 `$DSH_HOME/.env` 或环境中;启动器从不把凭据文件提升到 `process.env`,因此凭据可以轮换。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`;只有 patch 层插入提供方并启用 `web_fetch` 后,该工具才可用。
|
||||
基础组合包挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search`、repository Plugin 支持和会话遥测。提供方凭据依次从继承环境、`$DSH_HOME/.credentials.yaml`、调用目录的 `.env` 和 `$DSH_HOME/.env` 解析;受管文档从不物化进 `process.env`,而两个 `.env` 文件都是普通启动环境层。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`;只有 patch 层插入提供方并启用 `web_fetch` 后,该工具才可用。
|
||||
|
||||
会话事件默认作为 OTLP/HTTP 日志流式发送。`DSH_TELEMETRY_OTLP_URL` 选择其他 collector。任何非空 `DSH_TELEMETRY_DISABLED` 都会在启动前禁用遥测配置行。随附基础配置没有遥测脱敏规则,因此导出的记录可能包含消息文本、工具参数与结果以及 workspace 路径;该部署决策由[遥测 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md)负责。
|
||||
|
||||
|
||||
+3
-3
@@ -10,7 +10,7 @@
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { loadEnv } from '@deepseek-ai/dsh-app-boot'
|
||||
import { loadLayeredEnv } from '@deepseek-ai/dsh-app-boot'
|
||||
import { parseDshArgs } from './args.ts'
|
||||
|
||||
// Both the source tree (apps/cli/src) and the bundled bin (apps/cli/lib) sit
|
||||
@@ -24,13 +24,13 @@ function readVersion(): string {
|
||||
return typeof manifest.version === 'string' ? manifest.version : '0.0.0'
|
||||
}
|
||||
|
||||
loadEnv('dsh')
|
||||
const invocation = parseDshArgs(process.argv.slice(2), readVersion())
|
||||
|
||||
switch (invocation.mode) {
|
||||
case 'profile': {
|
||||
const { runProfile } = await import('./profile-boot.ts')
|
||||
await runProfile({
|
||||
environment: loadLayeredEnv('dsh'),
|
||||
profile: invocation.profile,
|
||||
patchFiles: invocation.patches,
|
||||
...invocation.task !== undefined && { task: invocation.task },
|
||||
@@ -39,7 +39,7 @@ switch (invocation.mode) {
|
||||
}
|
||||
case 'web': {
|
||||
const { runWeb } = await import('./web.ts')
|
||||
await runWeb(invocation)
|
||||
await runWeb(invocation, loadLayeredEnv('dsh'))
|
||||
break
|
||||
}
|
||||
case 'plugin': {
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
type Profile,
|
||||
} from '@deepseek-ai/dsh-app-boot'
|
||||
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
import { DSH_ENVIRONMENT_KEY, type EnvironmentSnapshot } from '@deepseek-ai/dsh-environment'
|
||||
import type { HeadlessIo } from '@deepseek-ai/dsh-headless'
|
||||
import { createProcessShutdown, type ProcessShutdown } from './process-shutdown.ts'
|
||||
|
||||
@@ -163,6 +164,8 @@ export interface RunProfileOptions {
|
||||
task?: string
|
||||
/** Surface setup registered after Loader installation and before any config-tree entry mounts. */
|
||||
prepare?: (ctx: Context, rows: ProfileRows) => Promise<void> | void
|
||||
/** This run's frozen environment snapshot, provided to the tree before any entry mounts. */
|
||||
environment: EnvironmentSnapshot
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -226,6 +229,9 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
|
||||
// application must not mutate the objects later reloads recompose from.
|
||||
const ctx = await boot(NAME, rootConfig, structuredClone(allPatches(composed)), async (hostCtx) => {
|
||||
app.current = hostCtx
|
||||
// Before any config-tree entry mounts, so a plugin that resolves a
|
||||
// user-facing value at construction already sees this run's layers.
|
||||
hostCtx.provide(DSH_ENVIRONMENT_KEY, options.environment)
|
||||
if (options.task !== undefined) {
|
||||
const io: HeadlessIo = {
|
||||
stdout: process.stdout,
|
||||
|
||||
+4
-1
@@ -13,6 +13,7 @@ import { fileURLToPath } from 'node:url'
|
||||
import type { Context } from 'cordis'
|
||||
import type { PatchOptions } from '@cordisjs/plugin-include'
|
||||
import { addHarnessSourceSection } from '@deepseek-ai/dsh-app-boot'
|
||||
import type { EnvironmentSnapshot } from '@deepseek-ai/dsh-environment'
|
||||
import { runProfile, type ProfileRows } from './profile-boot.ts'
|
||||
|
||||
const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
|
||||
@@ -124,9 +125,11 @@ export function webSurfaceContextEnabled(rows: ProfileRows): boolean {
|
||||
* every boot. The URL line is printed by the web-app bundle's runtime row
|
||||
* after Loader settlement.
|
||||
* @param flags - the parsed `dsh web` flag family.
|
||||
* @param environment - this run's frozen environment snapshot.
|
||||
*/
|
||||
export async function runWeb(flags: WebFlags): Promise<void> {
|
||||
export async function runWeb(flags: WebFlags, environment: EnvironmentSnapshot): Promise<void> {
|
||||
await runProfile({
|
||||
environment,
|
||||
profile: 'web',
|
||||
patchFiles: flags.patches,
|
||||
deriveFlagPatches: rows => deriveWebFlagPatches(rows, flags),
|
||||
|
||||
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { startMockLlmServer } from '@deepseek-ai/dsh-llm-mock-server'
|
||||
import { execa } from 'execa'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
@@ -13,14 +14,21 @@ const invalidProvider = fileURLToPath(new URL('./fixtures/invalid-provider.cordi
|
||||
|
||||
async function runBuiltBin(
|
||||
args: readonly string[] = [],
|
||||
env: Record<string, string> = {},
|
||||
env: Readonly<Record<string, string | undefined>> = {},
|
||||
cwd?: string,
|
||||
): Promise<{ stdout: string; code: number; stderr: string }> {
|
||||
const childEnv = Object.fromEntries(
|
||||
Object.entries({ ...process.env, ...env })
|
||||
.filter((entry): entry is [string, string] => entry[1] !== undefined),
|
||||
)
|
||||
const result = await execa(process.execPath, [dshBin, ...args], {
|
||||
input: '',
|
||||
timeout: 25_000,
|
||||
killSignal: 'SIGKILL',
|
||||
reject: false,
|
||||
env,
|
||||
env: childEnv,
|
||||
extendEnv: false,
|
||||
...cwd === undefined ? {} : { cwd },
|
||||
})
|
||||
if (result.timedOut) {
|
||||
throw new Error(`dsh built bin did not exit within 25s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
|
||||
@@ -127,6 +135,44 @@ function startProfileLifecycle(fixture: ProfileLifecycleFixture) {
|
||||
})
|
||||
}
|
||||
|
||||
function createEnvironmentProbeProfile(home: string, project: string): void {
|
||||
const pluginFile = join(project, 'environment-probe.mjs')
|
||||
writeFileSync(pluginFile, [
|
||||
"export const name = 'environment-probe'",
|
||||
"export const inject = ['llm']",
|
||||
'export function apply(ctx) {',
|
||||
' void ctx.loader.await().then(async () => {',
|
||||
" let text = ''",
|
||||
' for await (const chunk of ctx.llm.stream({',
|
||||
" provider: 'deepseek-official',",
|
||||
" model: 'deepseek-v4-flash',",
|
||||
' messages: [],',
|
||||
' maxTokens: 32,',
|
||||
' })) {',
|
||||
" if (chunk.type === 'text-delta') text += chunk.text",
|
||||
' }',
|
||||
' process.stdout.write(`${text}\\n`)',
|
||||
" process.kill(process.pid, 'SIGTERM')",
|
||||
' })',
|
||||
'}',
|
||||
'',
|
||||
].join('\n'))
|
||||
const profileDir = join(home, 'profiles', 'environment-probe')
|
||||
mkdirSync(profileDir, { recursive: true })
|
||||
writeFileSync(join(profileDir, 'package.json'), JSON.stringify({
|
||||
name: 'dsh-profile-environment-probe',
|
||||
private: true,
|
||||
dependencies: {},
|
||||
dsh: { profile: { bundles: ['@deepseek-ai/dsh-base'] } },
|
||||
}, undefined, 2))
|
||||
writeFileSync(join(profileDir, 'cordis.patch.yml'), [
|
||||
'- insert:',
|
||||
' - id: environment-probe',
|
||||
` name: ${pathToFileURL(pluginFile).href}`,
|
||||
'',
|
||||
].join('\n'))
|
||||
}
|
||||
|
||||
describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', () => {
|
||||
it('requires --profile and rejects removed commands', async () => {
|
||||
const bare = await runBuiltBin()
|
||||
@@ -144,6 +190,17 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('does not load a project environment for --version', async () => {
|
||||
const project = mkdtempSync(join(tmpdir(), 'dsh-version-project-'))
|
||||
writeFileSync(join(project, '.env'), 'PATH=/project-only-path\n')
|
||||
try {
|
||||
const result = await runBuiltBin(['--version'], {}, project)
|
||||
expect(result).toEqual({ code: 0, stdout: '0.0.1', stderr: '' })
|
||||
} finally {
|
||||
rmSync(project, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('fails loud on a nonexistent profile with the plugin-command hint', async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'dsh-missing-profile-'))
|
||||
try {
|
||||
@@ -156,6 +213,47 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('uses the Harness-home environment and managed credential through the published entry', async () => {
|
||||
const apiKey = 'built-home-layer-key'
|
||||
const server = await startMockLlmServer({
|
||||
sequence: ['success'],
|
||||
apiKey,
|
||||
successText: 'home environment reached the mock',
|
||||
})
|
||||
const home = mkdtempSync(join(tmpdir(), 'dsh-home-environment-'))
|
||||
const project = mkdtempSync(join(tmpdir(), 'dsh-home-project-'))
|
||||
writeFileSync(join(home, '.env'), `DEEPSEEK_BASE_URL=${server.baseURL}\n`)
|
||||
writeFileSync(join(home, '.credentials.yaml'), `DEEPSEEK_API_KEY: ${apiKey}\n`, { mode: 0o600 })
|
||||
createEnvironmentProbeProfile(home, project)
|
||||
try {
|
||||
const result = await runBuiltBin(
|
||||
['--profile', 'environment-probe'],
|
||||
{
|
||||
DSH_HOME: home,
|
||||
DSH_TELEMETRY_DISABLED: '1',
|
||||
DEEPSEEK_API_KEY: undefined,
|
||||
DEEPSEEK_BASE_URL: undefined,
|
||||
},
|
||||
project,
|
||||
)
|
||||
expect(
|
||||
result.code,
|
||||
`${result.stderr}\nstdout:\n${result.stdout}\nmock requests: ${String(server.requests.length)}`,
|
||||
).toBe(0)
|
||||
expect(result.stdout).toBe('home environment reached the mock')
|
||||
expect(result.stdout).not.toContain(apiKey)
|
||||
expect(result.stderr).not.toContain(apiKey)
|
||||
expect(server.requests).toHaveLength(1)
|
||||
expect(server.requests[0]?.path).toBe('/chat/completions')
|
||||
expect(server.requests[0]?.headers.authorization).toBe(`Bearer ${apiKey}`)
|
||||
expect(JSON.stringify(server.requests[0]?.body)).not.toContain(apiKey)
|
||||
} finally {
|
||||
await server.close()
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
rmSync(project, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('reports a patch-overlay boot failure without hanging', async () => {
|
||||
// The HMR main watcher's initial scan once refreshed the include
|
||||
// mid-initial-apply, deadlocking the failing apply's rollback against the
|
||||
|
||||
@@ -134,7 +134,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
|
||||
await dialog.getByRole('textbox', { name: 'API 密钥', exact: true }).fill('sk-e2e-minimax')
|
||||
await dialog.getByRole('button', { name: '保存', exact: true }).click()
|
||||
// The profile lands in settings.yaml with only the derived reference, the
|
||||
// key value lands in the harness home's .env, the dormant route
|
||||
// key value lands in the harness home's .credentials.yaml, the dormant route
|
||||
// registers, and the topology frame invalidates the page into the row.
|
||||
await expect.poll(
|
||||
async () => dialog.getByRole('textbox', { name: 'API 密钥', exact: true }).count(),
|
||||
@@ -146,11 +146,11 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
|
||||
expect(document).toContain('minimax-cn:')
|
||||
expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY')
|
||||
expect(document).not.toContain('sk-e2e-minimax')
|
||||
const credentialFile = join(scaffold.harnessHome, '.env')
|
||||
const credentialFile = join(scaffold.harnessHome, '.credentials.yaml')
|
||||
await expect.poll(
|
||||
async () => readFile(credentialFile, 'utf8').catch(() => ''),
|
||||
{ timeout: 10_000 },
|
||||
).toContain('MINIMAX_CN_API_KEY=sk-e2e-minimax')
|
||||
).toContain('MINIMAX_CN_API_KEY: sk-e2e-minimax')
|
||||
expect(await page.content()).not.toContain('sk-e2e-minimax')
|
||||
}, 60_000)
|
||||
|
||||
@@ -230,7 +230,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
|
||||
async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'),
|
||||
{ timeout: 10_000 },
|
||||
).not.toContain('minimax-cn:')
|
||||
expect(await readFile(join(scaffold.harnessHome, '.env'), 'utf8'))
|
||||
expect(await readFile(join(scaffold.harnessHome, '.credentials.yaml'), 'utf8'))
|
||||
.not.toContain('MINIMAX_CN_API_KEY')
|
||||
await expect.poll(
|
||||
async () => page.getByRole('dialog', { name: '删除 minimax-cn?' }).count(),
|
||||
|
||||
@@ -113,8 +113,8 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
|
||||
await settings.getByRole('button', { name: '保存', exact: true }).click()
|
||||
await keyInput.waitFor({ state: 'detached', timeout: 15_000 })
|
||||
|
||||
const stored = await readFile(join(scaffold.harnessHome, '.env'), 'utf8')
|
||||
expect(stored.includes(`DEEPSEEK_API_KEY=${secret}`)).toBe(true)
|
||||
const stored = await readFile(join(scaffold.harnessHome, '.credentials.yaml'), 'utf8')
|
||||
expect(stored.includes(`DEEPSEEK_API_KEY: ${secret}`)).toBe(true)
|
||||
expect((await page.content()).includes(secret)).toBe(false)
|
||||
expect((await page.locator('body').ariaSnapshot()).includes(secret)).toBe(false)
|
||||
expect(browserConsole.some(line => line.includes(secret))).toBe(false)
|
||||
|
||||
+10
-23
@@ -448,7 +448,7 @@ Source: [`packages/compact/compact-tool-result-prune/src/types.ts:4`](../package
|
||||
```ts config-catalog
|
||||
/** Plugin config: file location and hot-reload behavior. */
|
||||
export interface Config {
|
||||
/** Credentials document path; defaults to `.env` under the harness home. */
|
||||
/** Credentials document path; defaults to `.credentials.yaml` under the harness home. */
|
||||
path?: string
|
||||
/** Harness home used when `path` is omitted; defaults to `$DSH_HOME` or `~/.dsh`. */
|
||||
dshHome?: string
|
||||
@@ -459,7 +459,7 @@ export interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/credentials/credentials-local/src/index.ts:26`](../packages/credentials/credentials-local/src/index.ts)
|
||||
Source: [`packages/credentials/credentials-local/src/index.ts:54`](../packages/credentials/credentials-local/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-frontend-static`
|
||||
|
||||
@@ -701,17 +701,9 @@ Requires: `llm`
|
||||
* reasoning effort resolves to `high`.
|
||||
*/
|
||||
export interface Config {
|
||||
/**
|
||||
* Trimmed literal API key; whitespace-only is absent, so it resolves through
|
||||
* {@link apiKeyEnv} like an omitted one. Prefer {@link apiKeyEnv} to keep
|
||||
* secrets out of configuration files. {@link resolveAdapterOptions} also
|
||||
* format-checks what remains: a value no HTTP header can carry fails there
|
||||
* rather than inside `fetch`.
|
||||
*/
|
||||
apiKey?: string
|
||||
/** Credential reference (environment-variable name) resolved per request; defaults to `DEEPSEEK_API_KEY`. */
|
||||
apiKeyEnv?: string
|
||||
/** Endpoint base; falls back to $DEEPSEEK_BASE_URL, then the public API. */
|
||||
/** Endpoint base; falls back to $DEEPSEEK_BASE_URL from a trusted environment layer, then the public API. */
|
||||
baseURL?: string
|
||||
/** Deployment thinking policy; `disabled` limits every conversation request to `off`. */
|
||||
thinking?: 'enabled' | 'disabled'
|
||||
@@ -746,7 +738,7 @@ export interface DeepSeekCatalogModel {
|
||||
|
||||
Depends on: [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts)
|
||||
|
||||
Source: [`packages/llm/llm-deepseek/src/index.ts:60`](../packages/llm/llm-deepseek/src/index.ts)
|
||||
Source: [`packages/llm/llm-deepseek/src/index.ts:61`](../packages/llm/llm-deepseek/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-llm-pi-ai`
|
||||
|
||||
@@ -765,12 +757,6 @@ export interface Config {
|
||||
|
||||
/** Configuration for one pi-ai provider route; the `providers` dict key IS the route. */
|
||||
export interface PiAiProviderProfile {
|
||||
/**
|
||||
* Literal provider credential; prefer {@link apiKeyEnv}. With both absent pi-ai uses its
|
||||
* provider-native ambient discovery. Trimmed and format-checked by {@link resolveProfiles}; a
|
||||
* value no HTTP header can carry fails there rather than inside `fetch`.
|
||||
*/
|
||||
apiKey?: string
|
||||
/** Credential reference (environment-variable name) resolved per request through `ctx.credentials`. */
|
||||
apiKeyEnv?: string
|
||||
/** Name shown by configuration surfaces; defaults to the route key. */
|
||||
@@ -841,7 +827,7 @@ export interface PiAiModelProfile {
|
||||
|
||||
Depends on: `CacheRetention` (`@earendil-works/pi-ai`) · `ModelThinkingLevel` (`@earendil-works/pi-ai`) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · `ThinkingBudgets` (`@earendil-works/pi-ai`) · `Transport` (`@earendil-works/pi-ai`)
|
||||
|
||||
Source: [`packages/llm/llm-pi-ai/src/config.ts:126`](../packages/llm/llm-pi-ai/src/config.ts)
|
||||
Source: [`packages/llm/llm-pi-ai/src/config.ts:120`](../packages/llm/llm-pi-ai/src/config.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-llm-replay`
|
||||
|
||||
@@ -893,7 +879,7 @@ export interface ReplayModelConfig {
|
||||
|
||||
Depends on: [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts)
|
||||
|
||||
Source: [`packages/support/llm-replay/src/index.ts:710`](../packages/support/llm-replay/src/index.ts)
|
||||
Source: [`packages/support/llm-replay/src/index.ts:731`](../packages/support/llm-replay/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-llm-retry`
|
||||
|
||||
@@ -2435,7 +2421,7 @@ export interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/web/web-search-deepseek/src/index.ts:43`](../packages/web/web-search-deepseek/src/index.ts)
|
||||
Source: [`packages/web/web-search-deepseek/src/index.ts:44`](../packages/web/web-search-deepseek/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-web-search-exa`
|
||||
|
||||
@@ -2457,7 +2443,7 @@ export interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/web/web-search-exa/src/index.ts:37`](../packages/web/web-search-exa/src/index.ts)
|
||||
Source: [`packages/web/web-search-exa/src/index.ts:38`](../packages/web/web-search-exa/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-web-search-perplexity`
|
||||
|
||||
@@ -2479,7 +2465,7 @@ export interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/web/web-search-perplexity/src/index.ts:31`](../packages/web/web-search-perplexity/src/index.ts)
|
||||
Source: [`packages/web/web-search-perplexity/src/index.ts:32`](../packages/web/web-search-perplexity/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-workflow-workerthread`
|
||||
|
||||
@@ -2627,6 +2613,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them.
|
||||
- `@deepseek-ai/dsh-client-ui-slots` ([`packages/client/ui-slots/src/index.ts`](../packages/client/ui-slots/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-web` ([`packages/client/web/src/index.ts`](../packages/client/web/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-web-react` ([`packages/client/web-react/src/index.ts`](../packages/client/web-react/src/index.ts))
|
||||
- `@deepseek-ai/dsh-environment` ([`packages/util/environment/src/index.ts`](../packages/util/environment/src/index.ts))
|
||||
- `@deepseek-ai/dsh-helper` ([`packages/sdk/helper/src/index.ts`](../packages/sdk/helper/src/index.ts))
|
||||
- `@deepseek-ai/dsh-hook-protocol` ([`packages/hooks/hook-protocol/src/index.ts`](../packages/hooks/hook-protocol/src/index.ts))
|
||||
- `@deepseek-ai/dsh-jsonrpc-demo` ([`packages/examples/jsonrpc-demo/src/index.ts`](../packages/examples/jsonrpc-demo/src/index.ts))
|
||||
|
||||
@@ -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 docs/cordis-tutorial/05-config.md
|
||||
05-config.md: fc19add239636fa9e7071d9c77e48595caec1f08
|
||||
05-config.zh.md: 0c8170f518f0c87ab5c754606436496a4ff9d51e
|
||||
05-config.md: 8d4043e33a58fc425d82d9846ff82473bcdef4c1
|
||||
05-config.zh.md: e9463bd34e9c72dbae7b1ceb9907e35edf7b773b
|
||||
@@ -69,12 +69,12 @@ The plugin's fiber goes to FAILED, and this tutorial's launcher exits with statu
|
||||
|
||||
## Computed config values
|
||||
|
||||
The loader used in this repo supports a `!!js` tag for config values that must be computed at load time, such as reading an API key from the environment:
|
||||
The loader used in this repo supports a `!!js` tag for config values that must be computed at load time:
|
||||
|
||||
```yaml
|
||||
- name: '@deepseek-ai/dsh-llm-deepseek'
|
||||
- name: './config-demo.ts'
|
||||
config:
|
||||
apiKey: !!js process.env.DEEPSEEK_API_KEY
|
||||
greeting: !!js process.env.DEMO_GREETING ?? 'Hello'
|
||||
```
|
||||
|
||||
`!!js` works **only inside `config`**. Entry metadata (`name`, `id`, `disabled`, `inject`, ...) is static; `disabled: !!js ...` produces a truthy expression object that always disables the entry. See [loader configuration](../cordis-primer.md#loader-configuration).
|
||||
|
||||
@@ -69,12 +69,12 @@ ValidationError: invalid config:
|
||||
|
||||
## 计算得到的配置值
|
||||
|
||||
本仓库使用的 loader 支持 `!!js` 标签,用于必须在加载时计算的配置值,例如从环境中读取 API key:
|
||||
本仓库使用的 loader 支持 `!!js` 标签,用于必须在加载时计算的配置值:
|
||||
|
||||
```yaml
|
||||
- name: '@deepseek-ai/dsh-llm-deepseek'
|
||||
- name: './config-demo.ts'
|
||||
config:
|
||||
apiKey: !!js process.env.DEEPSEEK_API_KEY
|
||||
greeting: !!js process.env.DEMO_GREETING ?? 'Hello'
|
||||
```
|
||||
|
||||
`!!js` **仅在 `config` 内有效**。Cordis 配置项的元数据(`name`、`id`、`disabled`、`inject` 等)是静态的;`disabled: !!js ...` 会生成一个真值表达式对象,始终禁用该 Cordis 配置项。详见 [loader 配置](../cordis-primer.md#loader-configuration)。
|
||||
|
||||
@@ -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 docs/core-data-structures/credentials.md
|
||||
credentials.md: 3f6fcd127d01e2c49e17c70c002bebe9f363e951
|
||||
credentials.zh.md: b5d2d9e164a85ce090790635c438b768cae4c9ca
|
||||
credentials.md: ef74ddeb4346e18f8d5d33488657e5d50f1d754e
|
||||
credentials.zh.md: 09cf374a2346fd93aa834e3372321e6eeece6ed8
|
||||
@@ -24,7 +24,7 @@ type CredentialRef = Branded<'CredentialRef'>
|
||||
interface ResolvedCredential {
|
||||
/** The non-empty secret value. */
|
||||
value: string
|
||||
/** Provider-defined source layer id (the local provider uses `env` and `file`). */
|
||||
/** Provider-defined source layer id (the local provider uses `env`, `file`, `project-env`, and `user-env`). */
|
||||
source: string
|
||||
}
|
||||
```
|
||||
|
||||
@@ -24,7 +24,7 @@ type CredentialRef = Branded<'CredentialRef'>
|
||||
interface ResolvedCredential {
|
||||
/** The non-empty secret value. */
|
||||
value: string
|
||||
/** Provider-defined source layer id (the local provider uses `env` and `file`). */
|
||||
/** Provider-defined source layer id (the local provider uses `env`, `file`, `project-env`, and `user-env`). */
|
||||
source: string
|
||||
}
|
||||
```
|
||||
|
||||
+22
-11
@@ -10,6 +10,7 @@ flowchart TD
|
||||
subgraph group_util["packages/util"]
|
||||
pkg_atomic_write["atomic-write"]
|
||||
pkg_brand["brand"]
|
||||
pkg_environment["environment"]
|
||||
pkg_native_command["native-command"]
|
||||
pkg_paths["paths"]
|
||||
pkg_retention["retention"]
|
||||
@@ -292,6 +293,7 @@ flowchart TD
|
||||
end
|
||||
pkg_atomic_write --> pkg_invariants
|
||||
pkg_brand --> pkg_invariants
|
||||
pkg_environment --> pkg_invariants
|
||||
pkg_native_command --> pkg_invariants
|
||||
pkg_paths --> pkg_invariants
|
||||
pkg_retention --> pkg_invariants
|
||||
@@ -350,11 +352,13 @@ flowchart TD
|
||||
pkg_typert_loader --> pkg_invariants
|
||||
pkg_typert_loader --> pkg_typert_registry
|
||||
pkg_llm_deepseek --> pkg_credentials
|
||||
pkg_llm_deepseek --> pkg_environment
|
||||
pkg_llm_deepseek --> pkg_invariants
|
||||
pkg_llm_deepseek --> pkg_llm
|
||||
pkg_llm_deepseek --> pkg_settings
|
||||
pkg_llm_deepseek --> pkg_timeout
|
||||
pkg_llm_pi_ai --> pkg_credentials
|
||||
pkg_llm_pi_ai --> pkg_environment
|
||||
pkg_llm_pi_ai --> pkg_invariants
|
||||
pkg_llm_pi_ai --> pkg_llm
|
||||
pkg_llm_pi_ai --> pkg_settings
|
||||
@@ -374,6 +378,7 @@ flowchart TD
|
||||
pkg_api_gateway --> pkg_typert_registry
|
||||
pkg_credentials_local --> pkg_atomic_write
|
||||
pkg_credentials_local --> pkg_credentials
|
||||
pkg_credentials_local --> pkg_environment
|
||||
pkg_credentials_local --> pkg_invariants
|
||||
pkg_credentials_local --> pkg_paths
|
||||
pkg_lsp --> pkg_brand
|
||||
@@ -404,8 +409,10 @@ flowchart TD
|
||||
pkg_web_fetch_local --> pkg_invariants
|
||||
pkg_web_fetch_local --> pkg_timeout
|
||||
pkg_web_fetch_local --> pkg_web
|
||||
pkg_web_search_exa --> pkg_environment
|
||||
pkg_web_search_exa --> pkg_invariants
|
||||
pkg_web_search_exa --> pkg_web
|
||||
pkg_web_search_perplexity --> pkg_environment
|
||||
pkg_web_search_perplexity --> pkg_invariants
|
||||
pkg_web_search_perplexity --> pkg_web
|
||||
pkg_spill --> pkg_brand
|
||||
@@ -415,9 +422,7 @@ flowchart TD
|
||||
pkg_session_persistence --> pkg_brand
|
||||
pkg_session_persistence --> pkg_invariants
|
||||
pkg_session_persistence --> pkg_session
|
||||
pkg_llm_replay --> pkg_invariants
|
||||
pkg_llm_replay --> pkg_llm
|
||||
pkg_llm_replay --> pkg_session
|
||||
pkg_app_boot --> pkg_environment
|
||||
pkg_app_boot --> pkg_invariants
|
||||
pkg_app_boot --> pkg_paths
|
||||
pkg_app_boot --> pkg_system_prompt
|
||||
@@ -472,6 +477,7 @@ flowchart TD
|
||||
pkg_skill_local --> pkg_skill
|
||||
pkg_web_search_deepseek --> pkg_agent
|
||||
pkg_web_search_deepseek --> pkg_credentials
|
||||
pkg_web_search_deepseek --> pkg_environment
|
||||
pkg_web_search_deepseek --> pkg_invariants
|
||||
pkg_web_search_deepseek --> pkg_session
|
||||
pkg_web_search_deepseek --> pkg_web
|
||||
@@ -491,6 +497,10 @@ flowchart TD
|
||||
pkg_session_title --> pkg_llm
|
||||
pkg_session_title --> pkg_session
|
||||
pkg_session_title --> pkg_session_projection
|
||||
pkg_llm_replay --> pkg_compact
|
||||
pkg_llm_replay --> pkg_invariants
|
||||
pkg_llm_replay --> pkg_llm
|
||||
pkg_llm_replay --> pkg_session
|
||||
pkg_commands --> pkg_agent
|
||||
pkg_commands --> pkg_brand
|
||||
pkg_commands --> pkg_invariants
|
||||
@@ -1151,6 +1161,7 @@ flowchart TD
|
||||
| [`invariants`](../packages/support/invariants) | `support` | — |
|
||||
| [`atomic-write`](../packages/util/atomic-write) | `util` | [`invariants`](../packages/support/invariants) |
|
||||
| [`brand`](../packages/util/brand) | `util` | [`invariants`](../packages/support/invariants) |
|
||||
| [`environment`](../packages/util/environment) | `util` | [`invariants`](../packages/support/invariants) |
|
||||
| [`native-command`](../packages/util/native-command) | `util` | [`invariants`](../packages/support/invariants) |
|
||||
| [`paths`](../packages/util/paths) | `util` | [`invariants`](../packages/support/invariants) |
|
||||
| [`retention`](../packages/util/retention) | `util` | [`invariants`](../packages/support/invariants) |
|
||||
@@ -1190,13 +1201,13 @@ flowchart TD
|
||||
| [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) |
|
||||
| [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
|
||||
| [`typert-loader`](../packages/typert/loader) | `typert` | [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) |
|
||||
| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) |
|
||||
| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) |
|
||||
| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) |
|
||||
| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) |
|
||||
| [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`type-meta`](../packages/typert/type-meta) |
|
||||
| [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
|
||||
| [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) |
|
||||
| [`api-gateway`](../packages/api/gateway) | `api` | [`client-connection`](../packages/client/connection), [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) |
|
||||
| [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) |
|
||||
| [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) |
|
||||
| [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) |
|
||||
| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) |
|
||||
| [`settings-local`](../packages/settings/settings-local) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`settings`](../packages/settings/settings) |
|
||||
@@ -1205,12 +1216,11 @@ flowchart TD
|
||||
| [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) |
|
||||
| [`compact`](../packages/compact/compact) | `compact` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
|
||||
| [`web-fetch-local`](../packages/web/web-fetch-local) | `web` | [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) |
|
||||
| [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`invariants`](../packages/support/invariants), [`web`](../packages/web/web) |
|
||||
| [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`invariants`](../packages/support/invariants), [`web`](../packages/web/web) |
|
||||
| [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`web`](../packages/web/web) |
|
||||
| [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`web`](../packages/web/web) |
|
||||
| [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
|
||||
| [`session-persistence`](../packages/session-persistence/session-persistence) | `session-persistence` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
|
||||
| [`llm-replay`](../packages/support/llm-replay) | `support` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
|
||||
| [`app-boot`](../packages/ui/app-boot) | `ui` | [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`system-prompt`](../packages/core/system-prompt) |
|
||||
| [`app-boot`](../packages/ui/app-boot) | `ui` | [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`system-prompt`](../packages/core/system-prompt) |
|
||||
| [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
|
||||
| [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
|
||||
| [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) |
|
||||
@@ -1223,12 +1233,13 @@ flowchart TD
|
||||
| [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) |
|
||||
| [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) |
|
||||
| [`skill-local`](../packages/skill/skill-local) | `skill` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`skill`](../packages/skill/skill) |
|
||||
| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`web`](../packages/web/web) |
|
||||
| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`web`](../packages/web/web) |
|
||||
| [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/support/invariants), [`spill`](../packages/spill/spill) |
|
||||
| [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
|
||||
| [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl) | `session-persistence` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) |
|
||||
| [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | `session-persistence` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) |
|
||||
| [`session-title`](../packages/session-title/session-title) | `session-title` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection) |
|
||||
| [`llm-replay`](../packages/support/llm-replay) | `support` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
|
||||
| [`commands`](../packages/ui/commands) | `ui` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session) |
|
||||
| [`user-approval`](../packages/ui/user-approval) | `ui` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) |
|
||||
| [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) |
|
||||
|
||||
@@ -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 docs/user/guide/config.md
|
||||
config.md: 0f1a99ed0afdab13d052ae2714fc2e1718d3d85e
|
||||
config.zh.md: 74e14e4e6e1fbb38a7a8d5167a4747080a662128
|
||||
config.md: 34ab38c60cc7a9b6026f5be2be47f440eb6cb08d
|
||||
config.zh.md: ce965dfc67c759ffcbad7b044b74abb2025851c2
|
||||
@@ -18,10 +18,6 @@ A minimal configuration is a list of plugin entries:
|
||||
```yaml
|
||||
- id: llm-deepseek
|
||||
name: '@deepseek-ai/dsh-llm-deepseek'
|
||||
config:
|
||||
apiKey: !!js process.env.DEEPSEEK_API_KEY
|
||||
models:
|
||||
- deepseek-v4-flash
|
||||
|
||||
- id: bash
|
||||
name: '@deepseek-ai/dsh-bash-local'
|
||||
@@ -53,15 +49,14 @@ Cordis starts sibling entries concurrently. A plugin declares required services
|
||||
|
||||
`dsh --profile <name>` composes the profile's bundle patch layers (its manifest's `dsh.profile.bundles` list, in order) over an empty root, then the profile's own `~/.dsh/profiles/<name>/cordis.patch.yml`, then each `--patch <path>` overlay, then CLI-flag patches. Later layers win per row.
|
||||
|
||||
A patch replaces a row's entire `config` value; it does not deep-merge keys. For example, patching `llm-deepseek` with only `config: { thinking: disabled }` also removes that row's configured `apiKey` and `baseURL`, so restate every key the row must retain.
|
||||
A patch replaces a row's entire `config` value; it does not deep-merge keys. For example, patching `llm-deepseek` with only `config: { thinking: disabled }` also removes that row's configured `apiKeyEnv` and `baseURL`, so restate every key the row must retain.
|
||||
|
||||
## JavaScript values and environment variables
|
||||
|
||||
The Cordis loader evaluates runtime expressions tagged with `!!js`. Keep API keys and other secrets in the gitignored `.env` file at the repository root, never in committed configuration.
|
||||
The Cordis loader evaluates runtime expressions tagged with `!!js` for non-secret runtime values. Bundled LLM adapters carry credential references such as `apiKeyEnv`; the value belongs in an environment layer or `$DSH_HOME/.credentials.yaml`, not Cordis configuration.
|
||||
|
||||
```yaml
|
||||
config:
|
||||
apiKey: !!js process.env.DEEPSEEK_API_KEY
|
||||
cwd: !!js process.cwd()
|
||||
```
|
||||
|
||||
|
||||
@@ -18,10 +18,6 @@ Harness 使用 `cordis.yml` 描述 agent(智能体)加载哪些插件以及
|
||||
```yaml
|
||||
- id: llm-deepseek
|
||||
name: '@deepseek-ai/dsh-llm-deepseek'
|
||||
config:
|
||||
apiKey: !!js process.env.DEEPSEEK_API_KEY
|
||||
models:
|
||||
- deepseek-v4-flash
|
||||
|
||||
- id: bash
|
||||
name: '@deepseek-ai/dsh-bash-local'
|
||||
@@ -53,15 +49,14 @@ Cordis 会并发启动同级配置项。插件通过 `inject` 声明必需服务
|
||||
|
||||
`dsh --profile <name>` 按该 profile 的 manifest(元数据清单)中 `dsh.profile.bundles` 列表的顺序,在空根之上组合各组合包补丁层,随后依次应用该 profile 自己的 `~/.dsh/profiles/<name>/cordis.patch.yml`、每个 `--patch <path>` overlay,最后是 CLI(命令行界面)标志补丁。同一行以较后的层为准。
|
||||
|
||||
补丁会替换目标行的整个 `config` 值,而不是深度合并各个键。例如,只用 `config: { thinking: disabled }` 修补 `llm-deepseek`,也会移除该行原有的 `apiKey` 与 `baseURL`;因此必须重新写出该行需要保留的全部键。
|
||||
补丁会替换目标行的整个 `config` 值,而不是深度合并各个键。例如,只用 `config: { thinking: disabled }` 修补 `llm-deepseek`,也会移除该行原有的 `apiKeyEnv` 与 `baseURL`;因此必须重新写出该行需要保留的全部键。
|
||||
|
||||
## JavaScript 值和环境变量
|
||||
|
||||
Cordis loader 使用 `!!js` 标签读取运行时表达式。API key 等凭据应放在仓库根目录、已被 Git 忽略的 `.env` 中,不能提交到配置文件。
|
||||
Cordis loader 会求值以 `!!js` 标记的运行时表达式,用于非机密的运行时值。仓库内置的 LLM(大语言模型)适配器携带 `apiKeyEnv` 等凭据引用;对应的值应放在环境层或 `$DSH_HOME/.credentials.yaml`,而不是 Cordis 配置中。
|
||||
|
||||
```yaml
|
||||
config:
|
||||
apiKey: !!js process.env.DEEPSEEK_API_KEY
|
||||
cwd: !!js process.cwd()
|
||||
```
|
||||
|
||||
|
||||
@@ -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 docs/user/guide/index.md
|
||||
index.md: 4bb9f2e0056792a160877515f142eb36d4f680ac
|
||||
index.zh.md: 2792547a146b5ca6186bcb69c1c026744e80b326
|
||||
index.md: ede09506a996193fe5cf4ae6cd9b64d3529798a6
|
||||
index.zh.md: 5f72a6d3099d2d4721eccebae92eacfe72d33bce
|
||||
@@ -11,8 +11,6 @@ Harness implements every capability an AI agent needs—including LLM calls, too
|
||||
```yaml
|
||||
# Select the LLM backend
|
||||
- name: '@deepseek-ai/dsh-llm-deepseek'
|
||||
config:
|
||||
apiKey: !!js process.env.DEEPSEEK_API_KEY
|
||||
|
||||
# Select the one-shot application
|
||||
- id: cli-agent
|
||||
|
||||
@@ -11,8 +11,6 @@ Harness 将一个 AI Agent(智能体) 所需要的所有能力——LLM 调
|
||||
```yaml
|
||||
# Select the LLM backend
|
||||
- name: '@deepseek-ai/dsh-llm-deepseek'
|
||||
config:
|
||||
apiKey: !!js process.env.DEEPSEEK_API_KEY
|
||||
|
||||
# Select the one-shot application
|
||||
- id: cli-agent
|
||||
|
||||
@@ -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 docs/user/guide/providers.md
|
||||
providers.md: 5234f5bb03755c11652eb23f3c5d677fa3cddb40
|
||||
providers.zh.md: a54819cab8524a6007c335ad70cecd6516bba25b
|
||||
providers.md: 5fd11f3753468f9eccd318045b1ac5f6b3a21622
|
||||
providers.zh.md: 3e71e3fb2c64fb71fed196433175c860d3f6c817
|
||||
@@ -31,7 +31,7 @@ That holds for providers that authenticate with an API key. The catalog also car
|
||||
|
||||
**Let the endpoint report its models.** Expand **Model catalog** and choose **Fetch available models**: the interrogation asks the endpoint **the form currently shows** — including a base URL edited but not yet saved and a key typed but not yet stored — and offers what it reports as candidates to pick from. A route the installed catalog describes is answered from that catalog with no network call. Adopting a candidate only writes rows into the draft; nothing is stored until you save.
|
||||
|
||||
Keys are write-only: the page only ever holds a redacted descriptor, never the literal secret. A key you enter is stored in `$DSH_HOME/.env`, and the profile records only the variable name that references it.
|
||||
Keys are write-only: the page only ever holds a redacted descriptor, never the literal secret. A key you enter is stored in `$DSH_HOME/.credentials.yaml`, and the profile records only the variable name that references it.
|
||||
|
||||
## settings.yaml for advanced configuration
|
||||
|
||||
@@ -89,9 +89,9 @@ Model ids are not lifecycle configuration. Requesting a model the route does not
|
||||
|
||||
## Credentials
|
||||
|
||||
Prefer `apiKeyEnv`: it is a *reference* resolved per request, so no secret enters the configuration file. A literal `apiKey` is the escape hatch. Omitting both is what leaves a route unauthenticated, which for a catalog route means pi-ai's own environment discovery. A reference that resolves to nothing fails the request with `MISSING_CREDENTIAL` rather than falling through to whatever unrelated key the environment happens to hold.
|
||||
Use `apiKeyEnv`: it is a *reference* resolved per request, so no secret enters the configuration file. Omitting it leaves a route unauthenticated, which for a catalog route means pi-ai's own environment discovery. A reference that resolves to nothing fails the request with `MISSING_CREDENTIAL` rather than falling through to whatever unrelated key the environment happens to hold.
|
||||
|
||||
References resolve from `$DSH_HOME/.env` — what the Models page's key fields write — and from the matching environment variable when no credential service is mounted. One credential serves every model on its route.
|
||||
Under `dsh`, references resolve from the inherited environment, the Models page's `$DSH_HOME/.credentials.yaml` store, the invoking directory's `.env`, then `$DSH_HOME/.env`. Without a credential service, a reference reads only the matching environment variable. One credential serves every model on its route.
|
||||
|
||||
## Point an agent at the new provider
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ Harness 出厂自带 DeepSeek,同时预装了一个通用的多提供方适配
|
||||
|
||||
**让端点自己报模型。** 展开**模型目录**后点**获取可用模型**,会按你**当前表单里**的地址与密钥去问端点(地址改了但没保存、密钥刚输入还没存下,都算数),把它报告的模型列成候选让你勾选。内置目录里的路由直接由目录作答,不联网。采纳只是把行写进草稿,最终还是你点保存才落盘。
|
||||
|
||||
密钥是只写的:页面拿到的永远是脱敏描述符,不是明文。写入的密钥存进 `$DSH_HOME/.env`,profile 里只记录引用它的变量名。
|
||||
密钥是只写的:页面拿到的永远是脱敏描述符,不是明文。写入的密钥存进 `$DSH_HOME/.credentials.yaml`,profile 里只记录引用它的变量名。
|
||||
|
||||
## settings.yaml:进阶配置
|
||||
|
||||
@@ -89,9 +89,9 @@ settings 段落**逐个提供方**地盖在 `cordis.yml` 的同名配置之上
|
||||
|
||||
## 凭据
|
||||
|
||||
优先用 `apiKeyEnv`——它是一个**引用**,每次请求时解析,密钥本身不进配置文件;`apiKey` 字面量是应急出口。两者都不给,才表示这个路由不带认证,对内置目录路由意味着交给 pi-ai 自己的环境发现。给了引用却解析不到,请求会以 `MISSING_CREDENTIAL` 失败,而不是退回去用环境里碰巧存在的某个不相干的 key 计费。
|
||||
使用 `apiKeyEnv`——它是一个**引用**,每次请求时解析,密钥本身不进配置文件。省略它会让路由不带认证,对内置目录路由意味着交给 pi-ai 自己的环境发现。给了引用却解析不到,请求会以 `MISSING_CREDENTIAL` 失败,而不是退回去用环境里碰巧存在的某个不相干的 key 计费。
|
||||
|
||||
引用解析自 `$DSH_HOME/.env`(模型页的密钥输入框写的就是它),没有挂载凭据服务时则直接读同名环境变量。一份凭据供该路由上的所有模型使用。
|
||||
在 `dsh` 下,引用依次从继承环境、模型页的 `$DSH_HOME/.credentials.yaml` 存储、调用目录的 `.env` 和 `$DSH_HOME/.env` 解析。未挂载凭据服务时,引用只读取同名环境变量。一份凭据供该路由上的所有模型使用。
|
||||
|
||||
## 让 agent 用上新提供方
|
||||
|
||||
|
||||
@@ -9,8 +9,6 @@
|
||||
- id: llm-deepseek
|
||||
name: '@deepseek-ai/dsh-llm-deepseek'
|
||||
config:
|
||||
apiKey: !!js process.env.DEEPSEEK_API_KEY
|
||||
baseURL: !!js process.env.DEEPSEEK_BASE_URL
|
||||
thinking: enabled
|
||||
reasoningEffort: max
|
||||
models:
|
||||
|
||||
@@ -13,8 +13,6 @@
|
||||
- id: llm-deepseek
|
||||
name: '@deepseek-ai/dsh-llm-deepseek'
|
||||
config:
|
||||
apiKey: !!js process.env.DEEPSEEK_API_KEY
|
||||
baseURL: !!js process.env.DEEPSEEK_BASE_URL
|
||||
thinking: enabled
|
||||
reasoningEffort: max
|
||||
retryPolicy:
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
- id: llm-deepseek
|
||||
name: '@deepseek-ai/dsh-llm-deepseek'
|
||||
config:
|
||||
apiKey: !!js process.env.DEEPSEEK_API_KEY
|
||||
baseURL: !!js process.env.DEEPSEEK_BASE_URL
|
||||
models:
|
||||
- id: deepseek-v4-pro
|
||||
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
- id: llm-deepseek
|
||||
name: '@deepseek-ai/dsh-llm-deepseek'
|
||||
config:
|
||||
apiKey: !!js process.env.DEEPSEEK_API_KEY
|
||||
baseURL: !!js process.env.DEEPSEEK_BASE_URL
|
||||
models:
|
||||
- id: deepseek-v4-pro
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Keyless context-overflow composition for the assembled compaction snapshot.
|
||||
- id: base
|
||||
name: '@cordisjs/plugin-include'
|
||||
config:
|
||||
path: ./cordis.yml
|
||||
patches:
|
||||
- id: llm-deepseek
|
||||
name: '@deepseek-ai/dsh-llm-deepseek'
|
||||
disabled: true
|
||||
- id: compact-basic
|
||||
name: '@deepseek-ai/dsh-compact-basic'
|
||||
config:
|
||||
thresholdRatio: 0.99
|
||||
retainTokens: 20
|
||||
maxTokens: 32
|
||||
compactionRetries: 1
|
||||
- insert:
|
||||
- id: llm-replay
|
||||
name: '@deepseek-ai/dsh-llm-replay'
|
||||
config:
|
||||
providers:
|
||||
- id: deepseek-official
|
||||
models:
|
||||
- id: deepseek-v4-flash
|
||||
contextWindow: 128000
|
||||
@@ -9,7 +9,7 @@
|
||||
- id: settings
|
||||
name: '@deepseek-ai/dsh-settings-local'
|
||||
|
||||
# Credential store: the live process environment over `$DSH_HOME/.env`
|
||||
# Credential store: the live process environment over `$DSH_HOME/.credentials.yaml`
|
||||
# (owner-only file, hot-reloaded). The adapter resolves `DEEPSEEK_API_KEY`
|
||||
# through it at each request, so no key is inlined in this file.
|
||||
- id: credentials
|
||||
|
||||
@@ -11,8 +11,8 @@ import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
* Key-gated smoke for mid-session compaction. It verifies the compact event
|
||||
* pair, replacement of older surface nodes, and a final answer after compaction.
|
||||
*/
|
||||
// FIXME(compaction-snapshot): this is the only full compaction coverage because
|
||||
// replay cannot serve the summarizer's unlogged model call.
|
||||
// The keyless headless snapshot pins deterministic overflow recovery; this test
|
||||
// remains the independent live-provider smoke for organic pressure and summary quality.
|
||||
|
||||
let workdir: string | undefined
|
||||
let ctx: Context | undefined
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
patches:
|
||||
- id: llm-deepseek
|
||||
config:
|
||||
apiKey: snapshot-key
|
||||
baseURL: !!js process.env.DSH_SNAPSHOT_BASE_URL
|
||||
thinking: disabled
|
||||
- id: cli-agent
|
||||
|
||||
@@ -29,6 +29,10 @@ const goalScenarioDir = join(snapshotsDir, 'goal-tools')
|
||||
const goalConfigPath = fileURLToPath(new URL('../goal.cordis.snapshot.yml', import.meta.url))
|
||||
const retryScenarioDir = join(snapshotsDir, 'provider-retry')
|
||||
const retryConfigPath = fileURLToPath(new URL('../retry.cordis.snapshot.yml', import.meta.url))
|
||||
const compactionScenarioDir = join(snapshotsDir, 'compaction-recovery')
|
||||
const compactionSessionFixture = join(compactionScenarioDir, 'session.jsonl')
|
||||
const compactionStreamExpected = join(compactionScenarioDir, 'stream-json.expected.jsonl')
|
||||
const compactionConfigPath = fileURLToPath(new URL('../compaction.cordis.snapshot.yml', import.meta.url))
|
||||
const credentialsScenarioDir = join(snapshotsDir, 'missing-credential')
|
||||
const credentialsConfigPath = fileURLToPath(new URL('../credentials.cordis.snapshot.yml', import.meta.url))
|
||||
// Same keyless composition as the missing-credential scenario: the endpoint is
|
||||
@@ -227,6 +231,75 @@ describe('headless stream-json snapshots', () => {
|
||||
expect(normalized).toBe(await readFile(streamExpected, 'utf8'))
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it('recovers from context overflow through an assembled compaction', async () => {
|
||||
const prompt = await scenarioPrompt(compactionScenarioDir, 'compaction-recovery')
|
||||
let expectedSession = await readFile(compactionSessionFixture, 'utf8')
|
||||
let runCwd = ''
|
||||
const result = await runLoaderSmoke({
|
||||
label: 'compaction recovery headless stream-json snapshot',
|
||||
tempDirPrefix: 'headless-snapshot-compaction-recovery-',
|
||||
binScript,
|
||||
configPath: compactionConfigPath,
|
||||
binArgs: ['--config', compactionConfigPath, '--output-format', 'stream-json', prompt],
|
||||
tsconfigPath,
|
||||
env: {
|
||||
DSH_SNAPSHOT: 'replay',
|
||||
DSH_SNAPSHOT_FILE: compactionSessionFixture,
|
||||
NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '),
|
||||
},
|
||||
prepare: (cwd) => { runCwd = cwd },
|
||||
inspect: async (cwd) => {
|
||||
const logs = await persistedLogs(cwd)
|
||||
expect(logs).toHaveLength(1)
|
||||
const actual = logs[0]
|
||||
if (actual === undefined) throw new Error('compaction snapshot did not persist its session')
|
||||
const records = parseJsonl(actual.content)
|
||||
const types = records.map(record => record.type)
|
||||
expect(types.filter(type => type === 'compact/start')).toHaveLength(1)
|
||||
expect(types.filter(type => type === 'compact/summary')).toHaveLength(1)
|
||||
expect(types.filter(type => type === 'compact/end')).toHaveLength(1)
|
||||
const start = types.indexOf('compact/start')
|
||||
const summary = types.indexOf('compact/summary')
|
||||
const replacement = records.findIndex((record) => {
|
||||
if (record.type !== 'user/message') return false
|
||||
const surfaceOp = record.surfaceOp as JsonObject | undefined
|
||||
return surfaceOp?.op === 'replace'
|
||||
})
|
||||
const end = types.indexOf('compact/end')
|
||||
expect(start).toBeLessThan(summary)
|
||||
expect(summary).toBeLessThan(replacement)
|
||||
expect(replacement).toBeLessThan(end)
|
||||
const summaryRecord = records[summary]
|
||||
const summaryData = summaryRecord?.data as JsonObject | undefined
|
||||
expect(summaryData?.shadowedSeqs).toEqual(expect.arrayContaining([expect.any(Number)]))
|
||||
const final = [...records].reverse().find(record => record.type === 'assistant/message')
|
||||
expect(JSON.stringify(final)).toContain('COMPACTION RECOVERED')
|
||||
|
||||
const actualContext = contextFromLogs([actual.content])
|
||||
if (refreshing) {
|
||||
const harvested: HarvestedLog = {
|
||||
id: String(actual.header.id),
|
||||
createdAt: Number(actual.header.createdAt),
|
||||
content: actual.content,
|
||||
}
|
||||
const replacements = refreshFixtureReplacements([harvested], [expectedSession])
|
||||
expectedSession = tokenizeSessionFixtureCwd(
|
||||
stabilizeRefreshLog(actual.content, expectedSession, replacements, actualContext),
|
||||
)
|
||||
await writeFile(compactionSessionFixture, expectedSession)
|
||||
}
|
||||
const expectedContext = contextFromLogs([expectedSession])
|
||||
expect(scrubRequestHeaders(normalizeSessionLog(actual.content, actualContext)))
|
||||
.toBe(scrubRequestHeaders(normalizeSessionLog(expectedSession, expectedContext)))
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.stderr).toBe('')
|
||||
const normalized = normalizeHeadlessStream(result.stdout, runCwd)
|
||||
if (refreshing) await writeFile(compactionStreamExpected, normalized)
|
||||
expect(normalized).toBe(await readFile(compactionStreamExpected, 'utf8'))
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it('logs actionable missing-credential guidance through the one-shot app', async () => {
|
||||
const streamExpected = join(credentialsScenarioDir, 'stream-json.expected.jsonl')
|
||||
let runCwd = ''
|
||||
@@ -246,16 +319,22 @@ describe('headless stream-json snapshots', () => {
|
||||
prepare: (cwd) => { runCwd = cwd },
|
||||
})
|
||||
|
||||
// The failure reaches the caller through the stream, not stderr; the
|
||||
// recorded transcript below pins the guidance text itself, which names
|
||||
// both places a credential can come from and nothing else.
|
||||
expect(result.stderr).toBe('')
|
||||
const normalized = normalizeHeadlessStream(result.stdout, runCwd)
|
||||
if (refreshing) await writeFile(streamExpected, normalized)
|
||||
expect(normalized).toBe(await readFile(streamExpected, 'utf8'))
|
||||
// The durable failure leads with the credential store — the path that
|
||||
// keeps the secret out of configuration files — and offers a literal key last.
|
||||
// keeps the secret out of configuration files — then names the launching
|
||||
// environment, and stops there: configuration carries the reference, so
|
||||
// there is no literal-key escape hatch left to offer.
|
||||
expect(normalized).toContain(
|
||||
'store DEEPSEEK_API_KEY through the credentials service (the web Models page writes it),',
|
||||
)
|
||||
expect(normalized).toContain('as a last resort')
|
||||
expect(normalized).toContain('or export DEEPSEEK_API_KEY in the launching environment')
|
||||
expect(normalized).not.toContain('as a last resort')
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it('logs actionable invalid-credential guidance through the one-shot app', async () => {
|
||||
@@ -351,6 +430,9 @@ describe('headless stream-json snapshots', () => {
|
||||
],
|
||||
tsconfigPath,
|
||||
env: {
|
||||
// Configuration carries only the reference; the key rides the
|
||||
// launching environment, which is the whole credential plane here.
|
||||
DEEPSEEK_API_KEY: 'snapshot-key',
|
||||
DSH_SNAPSHOT_BASE_URL: server.url,
|
||||
NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '),
|
||||
},
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"steps": [
|
||||
{
|
||||
"op": "prompt",
|
||||
"text": "Establish a durable compaction premise before continuing. Record every part of this historical evidence: the snapshot uses keyless replay; persistence uses JSONL; the assembled headless application loads its real Cordis composition; model-visible inputs remain logged; tool calls and results remain paired and ordered; context overflow retains the original failure while recovery is attempted; compaction opens with compact/start and closes with compact/end; a successful auxiliary summary records compact/summary provenance; the replacement surface shadows only an older balanced range; the checkpoint remains smaller than the history it replaces; the newest tool result remains verbatim; the retried request sees that checkpoint; the final response proves the same turn continued; deterministic snapshot evidence stays separate from the live-provider smoke; and no external API key is needed. Emit one alpha marker through bash, then finish the task after any required recovery with the exact words COMPACTION RECOVERED."
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
+32
@@ -0,0 +1,32 @@
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":0,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Establish a durable compaction premise before continuing. Record every part of this historical evidence: the snapshot uses keyless replay; persistence uses JSONL; the assembled headless application loads its real Cordis composition; model-visible inputs remain logged; tool calls and results remain paired and ordered; context overflow retains the original failure while recovery is attempted; compaction opens with compact/start and closes with compact/end; a successful auxiliary summary records compact/summary provenance; the replacement surface shadows only an older balanced range; the checkpoint remains smaller than the history it replaces; the newest tool result remains verbatim; the retried request sees that checkpoint; the final response proves the same turn continued; deterministic snapshot evidence stays separate from the live-provider smoke; and no external API key is needed. Emit one alpha marker through bash, then finish the task after any required recovery with the exact words COMPACTION RECOVERED."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":1,"time":0,"data":{"turn":1}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":2,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":4,"time":0,"data":{"content":[{"type":"text","text":"Establish a durable compaction premise before continuing. Record every part of this historical evidence: the snapshot uses keyless replay; persistence uses JSONL; the assembled headless application loads its real Cordis composition; model-visible inputs remain logged; tool calls and results remain paired and ordered; context overflow retains the original failure while recovery is attempted; compaction opens with compact/start and closes with compact/end; a successful auxiliary summary records compact/summary provenance; the replacement surface shadows only an older balanced range; the checkpoint remains smaller than the history it replaces; the newest tool result remains verbatim; the retried request sees that checkpoint; the final response proves the same turn continued; deterministic snapshot evidence stays separate from the live-provider smoke; and no external API key is needed. Emit one alpha marker through bash, then finish the task after any required recovery with the exact words COMPACTION RECOVERED."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":5,"time":0,"data":{"title":"Establish a durable compaction premise","messageSeqs":[4],"source":{"kind":"fallback"}}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":6,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":7,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":128000}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_compaction_marker","name":"bash","argumentsDelta":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_compaction_marker","name":"bash","arguments":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}}}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":24,"outputTokens":6}}}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":13,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_compaction_marker","name":"bash","arguments":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":24,"outputTokens":6}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":14,"time":0,"data":{"turn":1,"step":1,"callId":"call_compaction_marker","name":"bash","arguments":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":15,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_compaction_marker"},"content":[{"type":"tool-result","toolCallId":"call_compaction_marker","content":[{"type":"text","text":"alpha\n"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":16,"time":0,"data":{"turn":1,"step":1}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":17,"time":0,"data":{"turn":1,"step":2}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"snapshot request exceeded the model context window","code":"CONTEXT_WINDOW_EXCEEDED"}}}}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"compact/start","seq":19,"time":0,"data":{"turn":1}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"compact/summary","seq":20,"time":0,"data":{"summary":[{"type":"text","text":"The request established a durable compaction premise."}],"rawOutput":[{"type":"text","text":"The request established a durable compaction premise."}],"shadowedRange":{"start":4,"end":4},"shadowedSeqs":[4],"shadowedTokenCount":264,"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":32,"usage":{"inputTokens":20,"outputTokens":4}}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":21,"time":0,"data":{"content":[{"type":"text","text":"This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.\n\n<compacted-summary>"},{"type":"text","text":"The request established a durable compaction premise."},{"type":"text","text":"</compacted-summary>"}],"source":{"kind":"plugin","plugin":"compact"},"role":"user","id":"{{sessionId}}"},"sourceEventSeqs":[19,20,4],"surfaceOp":{"op":"replace","start":4,"end":4}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"compact/end","seq":22,"time":0,"data":{"turn":1}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":23,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":24,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"COMPACTION RECOVERED"}}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"COMPACTION RECOVERED"}}}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":20,"outputTokens":4}}}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":28,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"COMPACTION RECOVERED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":20,"outputTokens":4}},"sourceEventSeqs":[23,24,25,26,27],"surfaceOp":"append"}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":29,"time":0,"data":{"turn":1,"step":2}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":30,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}
|
||||
{"type":"result","sessionId":"{{sessionId}}","output":"COMPACTION RECOVERED","usage":{"inputTokens":44,"outputTokens":10}}
|
||||
+2
-2
@@ -6,7 +6,7 @@
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":5,"time":0,"data":{"title":"say pong","messageSeqs":[4],"source":{"kind":"fallback"}}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":6,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":7,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":1000000}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"llm-deepseek: no API key for provider route \"deepseek-official\"; store DEEPSEEK_API_KEY through the credentials service (the web Models page writes it), export DEEPSEEK_API_KEY in the launching environment, or — as a last resort — set a literal \"apiKey\" in the llm-deepseek settings section","code":"MISSING_CREDENTIAL"}}}}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"llm-deepseek: no API key for provider route \"deepseek-official\"; store DEEPSEEK_API_KEY through the credentials service (the web Models page writes it), or export DEEPSEEK_API_KEY in the launching environment","code":"MISSING_CREDENTIAL"}}}}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":9,"time":0,"data":{"turn":1,"step":1}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":10,"time":0,"data":{"turn":1,"reason":{"kind":"error","error":{"message":"llm-deepseek: no API key for provider route \"deepseek-official\"; store DEEPSEEK_API_KEY through the credentials service (the web Models page writes it), export DEEPSEEK_API_KEY in the launching environment, or — as a last resort — set a literal \"apiKey\" in the llm-deepseek settings section","code":"MISSING_CREDENTIAL"}}}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":10,"time":0,"data":{"turn":1,"reason":{"kind":"error","error":{"message":"llm-deepseek: no API key for provider route \"deepseek-official\"; store DEEPSEEK_API_KEY through the credentials service (the web Models page writes it), or export DEEPSEEK_API_KEY in the launching environment","code":"MISSING_CREDENTIAL"}}}}}
|
||||
{"type":"result","sessionId":"{{sessionId}}","output":""}
|
||||
@@ -12,8 +12,6 @@
|
||||
- id: llm-deepseek
|
||||
name: '@deepseek-ai/dsh-llm-deepseek'
|
||||
config:
|
||||
apiKey: !!js process.env.DEEPSEEK_API_KEY
|
||||
baseURL: !!js process.env.DEEPSEEK_BASE_URL
|
||||
thinking: enabled
|
||||
reasoningEffort: max
|
||||
|
||||
|
||||
@@ -8,8 +8,6 @@
|
||||
- id: llm-deepseek
|
||||
name: '@deepseek-ai/dsh-llm-deepseek'
|
||||
config:
|
||||
apiKey: !!js process.env.DEEPSEEK_API_KEY
|
||||
baseURL: !!js process.env.DEEPSEEK_BASE_URL
|
||||
|
||||
- id: sandbox
|
||||
name: '@deepseek-ai/dsh-sandbox-local'
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
"verify-md-links": "tsx scripts/verify-md-links.ts",
|
||||
"verify-doc-refs": "tsx scripts/verify-doc-refs.ts",
|
||||
"verify-package-paths": "tsx scripts/verify-package-paths.ts",
|
||||
"verify-config-source-ownership": "tsx scripts/verify-config-source-ownership.ts",
|
||||
"verify-package-invariants": "tsx scripts/verify-package-invariants.ts",
|
||||
"verify-built-package-invariants": "node scripts/verify-built-package-invariants.mjs",
|
||||
"verify-package-readme-model-experience": "tsx scripts/verify-package-readme-model-experience.ts",
|
||||
|
||||
@@ -77,12 +77,10 @@
|
||||
- id: settings
|
||||
name: '@deepseek-ai/dsh-settings-local'
|
||||
|
||||
# Credential store: the live process environment over `$DSH_HOME/.env`
|
||||
# (owner-only file, hot-reloaded). Adapters resolve their key references
|
||||
# through it at each request, so no key is inlined in this file. The web
|
||||
# Models page's key inputs write it through `credentials.set`; nothing hoists
|
||||
# the document into the process environment, which would make every stored key
|
||||
# read as an unrotatable ambient override.
|
||||
# Credential sources: inherited environment over the managed
|
||||
# `$DSH_HOME/.credentials.yaml`, with project and user `.env` fallbacks.
|
||||
# Adapters resolve references per request; the Models page writes only the
|
||||
# managed document, which is never materialized into the process environment.
|
||||
- id: credentials
|
||||
name: '@deepseek-ai/dsh-credentials-local'
|
||||
|
||||
@@ -388,7 +386,6 @@
|
||||
name: '@deepseek-ai/dsh-web-search-deepseek'
|
||||
config:
|
||||
apiKeyEnv: DEEPSEEK_API_KEY
|
||||
baseURL: !!js process.env.DEEPSEEK_SEARCH_BASE_URL
|
||||
|
||||
- id: tool-web
|
||||
name: '@deepseek-ai/dsh-tool-web'
|
||||
|
||||
@@ -35,11 +35,6 @@
|
||||
# once the web UI owns the choice per session.
|
||||
mode: !!js process.env.DSH_TOOLS_MODE
|
||||
|
||||
- id: llm-deepseek
|
||||
config:
|
||||
apiKey: !!js process.env.DEEPSEEK_API_KEY
|
||||
baseURL: !!js process.env.DEEPSEEK_BASE_URL
|
||||
|
||||
# ── web-only host rows, the transport layer, and the browser roster ─────────
|
||||
|
||||
# `dshClient` rows are the browser roster the modules node half scans into
|
||||
|
||||
@@ -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 packages/client/ui-models/README.md
|
||||
README.md: cf4e50630339c4055e9ae2df37246b814af06966
|
||||
README.zh.md: 2b9158fa4419bf07f496fce47c938744f2a4233f
|
||||
README.md: 66f23d3f77adae23fcebd3adbb6c5c47ce16ae2f
|
||||
README.zh.md: dcb35c624aca73b558532210750f684e5cc54e85
|
||||
@@ -4,11 +4,11 @@ English | [中文](README.zh.md)
|
||||
|
||||
Models settings plugin: the provider configuration page and official-DeepSeek conditional onboarding step. It joins three wire domains into one shared snapshot — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time, without presenting route liveness as provider status.
|
||||
|
||||
Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The pi-ai card additionally edits that route's **model list** and can ask the provider what it serves. A row labels API-key state with a green solid dot only when a literal key or referenced credential is confirmed configured, and with a red solid dot only when a named reference is confirmed missing; reference-free provider-native authentication and unavailable credential enrichment remain unmarked. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. Leaving a new pi-ai provider's key blank saves a reference-free profile and therefore preserves provider-native authentication such as the Bedrock credential chain or Vertex ADC. A successful Apply emits a local accessible status message without echoing secret material. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint) and each adapter's model catalog. Reasoning effort is deliberately NOT among them: it is a per-model capability and the models under one provider disagree about which levels they accept, so a provider-scoped control could only be set to a value some of them reject — which took the whole provider out of the model picker. The composer's model picker offers each model its own levels, and a switch there records provider, model, and effort together as the default for the next session. The profile field stays in `settings.yaml` for a deployment that knows its route. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`; existing fields outside that curated set survive edits, while every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its localized confirmation dialog names the provider in the title, description, and final action. A row is tagged **Custom** when the directory entry says the owning adapter ships nothing under that key. The tag follows that answer alone: having a stored profile does not make a route custom — narrowing a shipped provider's models stores one too — and an adapter that reports nothing leaves its rows untagged rather than being read as shipped.
|
||||
Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The pi-ai card additionally edits that route's **model list** and can ask the provider what it serves. A row labels API-key state with a green solid dot only when a referenced credential is confirmed configured, and with a red solid dot only when a named reference is confirmed missing; reference-free provider-native authentication and unavailable credential enrichment remain unmarked. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. Leaving a new pi-ai provider's key blank saves a reference-free profile and therefore preserves provider-native authentication such as the Bedrock credential chain or Vertex ADC. A successful Apply emits a local accessible status message without echoing secret material. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint) and each adapter's model catalog. Reasoning effort is deliberately NOT among them: it is a per-model capability and the models under one provider disagree about which levels they accept, so a provider-scoped control could only be set to a value some of them reject — which took the whole provider out of the model picker. The composer's model picker offers each model its own levels, and a switch there records provider, model, and effort together as the default for the next session. The profile field stays in `settings.yaml` for a deployment that knows its route. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`; existing fields outside that curated set survive edits, while every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its localized confirmation dialog names the provider in the title, description, and final action. A row is tagged **Custom** when the directory entry says the owning adapter ships nothing under that key. The tag follows that answer alone: having a stored profile does not make a route custom — narrowing a shipped provider's models stores one too — and an adapter that reports nothing leaves its rows untagged rather than being read as shipped.
|
||||
|
||||
The DeepSeek step projects `deepseek-official` readiness from that same joined snapshot after earlier onboarding pages complete. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured literal `apiKey` secret sidecar or configured credential reference completes the step without rendering, including a read-only launch-environment credential. Only a mounted, active adapter with a missing writable reference shows the page that opens Settings on Models, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering so onboarding cannot block the product; Models remains the diagnostic surface.
|
||||
The DeepSeek step projects `deepseek-official` readiness from that same joined snapshot after earlier onboarding pages complete. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured credential reference completes the step without rendering, including a read-only launch-environment credential. Only a mounted, active adapter with a missing writable reference shows the page that opens Settings on Models, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering so onboarding cannot block the product; Models remains the diagnostic surface.
|
||||
|
||||
Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, the same shape the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. A typed API key is judged on its own field the same way: after trimming, it must be non-empty and every character must be printable ASCII (`[\x21-\x7E]`), which is exactly what an HTTP header value can carry — the twin of `normalizeApiKey` in `@deepseek-ai/dsh-llm`, mirrored here because the source-plane split forbids importing it. A value shaped like a pasted `NAME=value` environment line or wrapped in matching quotes is refused as the same format failure; that paste-shape heuristic runs only in the browser, since a false positive in a resolver would leave the environment refusing the key as well. A field holding only whitespace fails rather than being silently dropped, while an empty field is not a failure at all: it means keep the stored key on an editor card, and authenticate some other way on a create card. A refused key blocks both the write and the endpoint interrogation, so the page never spends a round trip to be told what the field already says. Each settings write carries the card's current `revision`, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict`; after settings commit, the card adopts the returned redacted user subtree and revision before storing the credential, which makes a failed credential stage retry only that stage. Deletion removes a configured, writable credential only when the profile names the page's derived `<ROUTE>_API_KEY` target, then unsets the profile; both operations are idempotent, and a partial failure remains in the identified confirmation dialog for retry. Environment credentials, custom references, and credentials whose target cannot be identified remain untouched. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling.
|
||||
Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it mutates the fields it can see rather than rebuilding a section. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, the same shape the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. A typed API key is judged on its own field the same way: after trimming, it must be non-empty and every character must be printable ASCII (`[\x21-\x7E]`), which is exactly what an HTTP header value can carry — the twin of `normalizeApiKey` in `@deepseek-ai/dsh-llm`, mirrored here because the source-plane split forbids importing it. A value shaped like a pasted `NAME=value` environment line or wrapped in matching quotes is refused as the same format failure; that paste-shape heuristic runs only in the browser, since a false positive in a resolver would leave the environment refusing the key as well. A field holding only whitespace fails rather than being silently dropped, while an empty field is not a failure at all: it means keep the stored key on an editor card, and authenticate some other way on a create card. A refused key blocks both the write and the endpoint interrogation, so the page never spends a round trip to be told what the field already says. Each settings write carries the card's current `revision`, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict`; after settings commit, the card adopts the returned redacted user subtree and revision before storing the credential, which makes a failed credential stage retry only that stage. Deletion removes a configured, writable credential only when the profile names the page's derived `<ROUTE>_API_KEY` target, then unsets the profile; both operations are idempotent, and a partial failure remains in the identified confirmation dialog for retry. Environment credentials, custom references, and credentials whose target cannot be identified remain untouched. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling.
|
||||
|
||||
## Model list and endpoint interrogation
|
||||
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
|
||||
模型设置插件:提供方配置页和按条件显示的 DeepSeek 官方首次使用引导步骤。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片,且不把路由存活状态呈现为提供方状态。
|
||||
|
||||
行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。pi-ai 卡片还会编辑该路由的**模型列表**,并可以询问提供方它服务什么。只有确认字面密钥或引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。无引用的提供方原生认证以及无法取得凭据补充信息时都不显示状态点。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `<ROUTE>_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供方留空密钥会保存一个不带引用的 profile,因此能保留提供方原生认证,例如 Bedrock 凭据链或 Vertex ADC。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),以及各适配器自己的模型目录。推理等级刻意**不在**其中:它是按模型的能力,而同一提供方下各模型接受的档位并不一致,因此提供方级的控件只可能被设成其中一些模型会拒绝的值——那会让整个提供方从模型选择器里消失。输入框的模型选择器为每个模型提供它自己的档位,在那里切换会把提供方、模型、推理等级一并记为下一个会话的默认值。profile 字段仍留在 `settings.yaml`,供清楚自己路由的部署使用。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),其本地化确认对话框会在标题、说明和最终操作中点名该提供方。当目录条目表明拥有该路由的适配器在这个键下什么都没有时,该行会带上 **自定义** 标签。标签只跟随这个答案:存了 profile 并不使一条路由成为自定义——收窄一个内置提供方的模型同样会存下 profile——而什么都不回答的适配器,其路由保持无标签,不会被当成内置。
|
||||
行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。pi-ai 卡片还会编辑该路由的**模型列表**,并可以询问提供方它服务什么。只有确认引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。无引用的提供方原生认证以及无法取得凭据补充信息时都不显示状态点。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `<ROUTE>_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供方留空密钥会保存一个不带引用的 profile,因此能保留提供方原生认证,例如 Bedrock 凭据链或 Vertex ADC。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),以及各适配器自己的模型目录。推理等级刻意**不在**其中:它是按模型的能力,而同一提供方下各模型接受的档位并不一致,因此提供方级的控件只可能被设成其中一些模型会拒绝的值——那会让整个提供方从模型选择器里消失。输入框的模型选择器为每个模型提供它自己的档位,在那里切换会把提供方、模型、推理等级一并记为下一个会话的默认值。profile 字段仍留在 `settings.yaml`,供清楚自己路由的部署使用。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),其本地化确认对话框会在标题、说明和最终操作中点名该提供方。当目录条目表明拥有该路由的适配器在这个键下什么都没有时,该行会带上 **自定义** 标签。标签只跟随这个答案:存了 profile 并不使一条路由成为自定义——收窄一个内置提供方的模型同样会存下 profile——而什么都不回答的适配器,其路由保持无标签,不会被当成内置。
|
||||
|
||||
前序首次使用引导页面完成后,DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,或凭据引用已配置,该步骤会直接完成而不渲染,其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置/凭据能力不可用时,该步骤均不渲染并直接完成,以免首次使用引导阻塞产品;Models 页仍是诊断界面。
|
||||
前序首次使用引导页面完成后,DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。凭据引用已配置时,该步骤会直接完成而不渲染,其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置/凭据能力不可用时,该步骤均不渲染并直接完成,以免首次使用引导阻塞产品;Models 页仍是诊断界面。
|
||||
|
||||
每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它点名自己看得见的字段,而不是重建分节:一个它从未收到过的已存字面机密不会被任何 op 提及,也就得以留存。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,与 pi-ai 提供方表单采用的形态相同。两项容量都按数值键入,可带十进制的 `K` 或 `M` 后缀(`256K`、`1M`;`1M` 即 1000K),存储为纯数值,回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称,以及无法读取、非正数或非整数的容量都会在写入前失败。键入的 API 密钥同样在它自己的字段上被判定:trim 之后必须非空,且每个字符都是可打印 ASCII(`[\x21-\x7E]`)——这正是 HTTP 标头值所能承载的范围,是 `@deepseek-ai/dsh-llm` 中 `normalizeApiKey` 的孪生体,因源码平面分割禁止直接引入而在此镜像。形如整行粘贴的 `NAME=value` 环境变量或首尾成对引号包裹的值,会以同一条格式失败被拒绝;该粘贴形状启发式只在浏览器中运行,因为 resolver 中的一次误判会连带让环境变量这条路也拒绝该密钥。只含空白的输入框会失败而不是被静默丢弃;留空则完全不是失败:在编辑卡片上意味着保持已存储的密钥,在新建卡片上则意味着以其他方式鉴权。被拒绝的密钥会同时拦截写入与端点探测,因此页面不会白花一次往返去换取字段上已经写明的答案。每次 settings 写入都携带卡片当前的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝;settings 提交成功后,卡片会在存储凭据前采用响应返回的脱敏用户子树与 revision,因此凭据阶段失败时,重试只会重复该阶段。删除操作只会在 profile 指向页面派生的 `<ROUTE>_API_KEY` 目标时清除已配置且可写的凭据,随后取消设置 profile;两项操作都具备幂等性,部分失败会停留在点名目标的确认对话框中供重试。环境凭据、自定义引用和无法识别目标的凭据保持不变。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。
|
||||
每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它只修改自己看得见的字段,而不重建分节。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,与 pi-ai 提供方表单采用的形态相同。两项容量都按数值键入,可带十进制的 `K` 或 `M` 后缀(`256K`、`1M`;`1M` 即 1000K),存储为纯数值,回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称,以及无法读取、非正数或非整数的容量都会在写入前失败。键入的 API 密钥同样在它自己的字段上被判定:trim 之后必须非空,且每个字符都是可打印 ASCII(`[\x21-\x7E]`)——这正是 HTTP 标头值所能承载的范围,是 `@deepseek-ai/dsh-llm` 中 `normalizeApiKey` 的孪生体,因源码平面分割禁止直接引入而在此镜像。形如整行粘贴的 `NAME=value` 环境变量或首尾成对引号包裹的值,会以同一条格式失败被拒绝;该粘贴形状启发式只在浏览器中运行,因为 resolver 中的一次误判会连带让环境变量这条路也拒绝该密钥。只含空白的输入框会失败而不是被静默丢弃;留空则完全不是失败:在编辑卡片上意味着保持已存储的密钥,在新建卡片上则意味着以其他方式鉴权。被拒绝的密钥会同时拦截写入与端点探测,因此页面不会白花一次往返去换取字段上已经写明的答案。每次 settings 写入都携带卡片当前的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝;settings 提交成功后,卡片会在存储凭据前采用响应返回的脱敏用户子树与 revision,因此凭据阶段失败时,重试只会重复该阶段。删除操作只会在 profile 指向页面派生的 `<ROUTE>_API_KEY` 目标时清除已配置且可写的凭据,随后取消设置 profile;两项操作都具备幂等性,部分失败会停留在点名目标的确认对话框中供重试。环境凭据、自定义引用和无法识别目标的凭据保持不变。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。
|
||||
|
||||
## 模型列表与端点询问
|
||||
|
||||
|
||||
@@ -80,8 +80,8 @@ function renderProviderEditor({ target, ...props }: ProviderEditorRenderProps):
|
||||
* Remove one user-added provider and its page-managed credential. Credential
|
||||
* removal comes first so a second-step failure leaves the provider row visible
|
||||
* and the whole operation safely retryable; both unsets are idempotent.
|
||||
* The settings removal names the profile rather than rebuilding its redacted
|
||||
* namespace, which would drop literal secrets stored elsewhere.
|
||||
* The settings removal names the profile rather than rebuilding its whole
|
||||
* namespace from a partial view.
|
||||
* @param api - settings and credential wire faces.
|
||||
* @param controller - the page store to refresh.
|
||||
* @param target - the provider's settings address and optional managed credential.
|
||||
@@ -112,16 +112,14 @@ export async function removeProviderProfile(
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a whole-section provider still needs its first key: nothing marks
|
||||
* the credential configured and no literal `apiKey` is stored, so the page
|
||||
* opens the setup card instead of showing a row.
|
||||
* Whether a whole-section provider still needs its first key: an unconfigured
|
||||
* credential opens the setup card instead of showing a row.
|
||||
* @param row - the joined provider row.
|
||||
* @returns whether to render the setup card.
|
||||
*/
|
||||
export function needsSetup(row: ProviderRow): boolean {
|
||||
if (row.entry.settingsPath.length > 0) return false
|
||||
if (row.credential?.configured === true) return false
|
||||
return !row.literalApiKeyConfigured
|
||||
return row.credential?.configured !== true
|
||||
}
|
||||
|
||||
function targetOf(row: ProviderRow): EditorTarget {
|
||||
@@ -264,7 +262,7 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
)
|
||||
}
|
||||
const open = !adding && editing?.provider === row.entry.provider
|
||||
const credentialConfigured = row.literalApiKeyConfigured || row.credential?.configured === true
|
||||
const credentialConfigured = row.credential?.configured === true
|
||||
const credentialMissing = !credentialConfigured
|
||||
&& row.apiKeyEnv !== undefined
|
||||
&& row.credential?.configured === false
|
||||
|
||||
@@ -14,9 +14,8 @@
|
||||
* model picker offers each model its own levels; `settings.yaml` keeps the
|
||||
* profile field for a deployment that knows its route. Everything else stays
|
||||
* owned by `settings.yaml`. Profile edits land as minimal `settings.mutate`
|
||||
* path ops against the stored section — the card reads the redacted
|
||||
* descriptor, so it names only the fields it can see and a stored literal
|
||||
* secret is never collaterally removed.
|
||||
* path ops against the stored section — the card names only the fields it can
|
||||
* see instead of rebuilding the whole subtree from a partial descriptor.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
@@ -72,10 +71,9 @@ function draftAt(namespace: SettingsNamespaceView, path: readonly string[]): Rec
|
||||
|
||||
/**
|
||||
* The minimal path ops carrying `after` over `before`, both as the card sees
|
||||
* them (that is, redacted). Only keys the card observed are named: a stored
|
||||
* `role('secret')` field appears in neither side, so it produces no op and
|
||||
* survives the write — the whole reason edits are path-addressed rather than
|
||||
* a rebuilt section.
|
||||
* them. Only keys the card observed are named; fields absent from both sides
|
||||
* produce no op, which is why edits are path-addressed rather than a rebuilt
|
||||
* section.
|
||||
* @param base - path of the edited subtree inside the user section.
|
||||
* @param before - the subtree as loaded, or undefined when it is new.
|
||||
* @param after - the subtree as edited.
|
||||
@@ -197,9 +195,8 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
/**
|
||||
* The write for this card, or a failure message. Every edit travels as
|
||||
* path ops against the STORED section: the draft comes from the redacted
|
||||
* descriptor, so a wholesale replace rebuilt from it would delete the
|
||||
* literal secrets the wire never returned. Ops name only the fields this
|
||||
* card can see, so a stored secret is untouched by construction.
|
||||
* descriptor, so a wholesale replace rebuilt from it could delete fields
|
||||
* outside the card. Ops name only the fields this card can see.
|
||||
*/
|
||||
const applyOnce = async (): Promise<string | undefined> => {
|
||||
const ns = namespace.ns
|
||||
|
||||
@@ -31,8 +31,6 @@ export interface ProviderRow {
|
||||
apiKeyEnv: string | undefined
|
||||
/** Credential state for {@link apiKeyEnv}, once described. */
|
||||
credential: CredentialView | undefined
|
||||
/** Whether the redacted secret sidecar reports an effective literal `apiKey`. */
|
||||
literalApiKeyConfigured: boolean
|
||||
}
|
||||
|
||||
/** Page snapshot. */
|
||||
@@ -97,19 +95,6 @@ function apiKeyEnvOf(namespace: SettingsNamespaceView | undefined, path: readonl
|
||||
return typeof ref === 'string' && ref.length > 0 ? ref : undefined
|
||||
}
|
||||
|
||||
/** Whether one namespace's redacted sidecar reports a set literal API key. */
|
||||
function literalApiKeyConfigured(
|
||||
namespace: SettingsNamespaceView | undefined,
|
||||
path: readonly string[],
|
||||
): boolean {
|
||||
if (namespace === undefined) return false
|
||||
const secretPath = [...path, 'apiKey']
|
||||
return namespace.secrets.some(secret =>
|
||||
secret.set
|
||||
&& secret.path.length === secretPath.length
|
||||
&& secret.path.every((key, index) => key === secretPath[index]))
|
||||
}
|
||||
|
||||
/** The models settings page controller (one per settings surface). */
|
||||
export class ModelsSettingsStore {
|
||||
/** The snapshot the section renders from (uSES-safe store). */
|
||||
@@ -170,7 +155,6 @@ export class ModelsSettingsStore {
|
||||
removable,
|
||||
apiKeyEnv: apiKeyEnvOf(namespace, entry.settingsPath),
|
||||
credential: undefined,
|
||||
literalApiKeyConfigured: literalApiKeyConfigured(namespace, entry.settingsPath),
|
||||
}
|
||||
})
|
||||
const refs = [...new Set(rows.flatMap(row => row.apiKeyEnv === undefined ? [] : [row.apiKeyEnv]))]
|
||||
@@ -257,7 +241,6 @@ export function deepSeekReadiness(state: ModelsSettingsState): DeepSeekReadiness
|
||||
reason: 'settings-unavailable',
|
||||
}
|
||||
}
|
||||
if (row.literalApiKeyConfigured) return { kind: 'configured' }
|
||||
if (row.apiKeyEnv === undefined) {
|
||||
return {
|
||||
kind: 'unavailable',
|
||||
|
||||
@@ -35,9 +35,7 @@ function capacityInputs(label: string): HTMLInputElement[] {
|
||||
}
|
||||
|
||||
const PiAiConfig = Schema.object({
|
||||
token: Schema.string().role('secret'),
|
||||
providers: Schema.dict(Schema.object({
|
||||
apiKey: Schema.string().role('secret'),
|
||||
apiKeyEnv: Schema.string().role('credential-ref'),
|
||||
baseURL: Schema.string(),
|
||||
reasoning: Schema.union(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']),
|
||||
@@ -46,7 +44,6 @@ const PiAiConfig = Schema.object({
|
||||
})
|
||||
|
||||
const DeepSeekConfig = Schema.object({
|
||||
apiKey: Schema.string().role('secret'),
|
||||
apiKeyEnv: Schema.string().role('credential-ref'),
|
||||
baseURL: Schema.string().pattern(/^https:\/\//),
|
||||
reasoningEffort: Schema.union(['off', 'high', 'max']),
|
||||
@@ -99,7 +96,7 @@ function wireNamespaces(): SettingsNamespaceView[] {
|
||||
base: { defaultContextWindow: 1_000_000, maxTokens: 256_000, models: DEFAULT_DEEPSEEK_MODELS },
|
||||
user: { baseURL: 'https://base' },
|
||||
applies: 'live',
|
||||
secrets: [{ path: ['apiKey'], set: false }],
|
||||
secrets: [],
|
||||
revision: 0,
|
||||
},
|
||||
{
|
||||
@@ -118,7 +115,7 @@ function wireNamespaces(): SettingsNamespaceView[] {
|
||||
value: { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://proxy', headers: { 'X-Team': 'a' } }, zombie: {} } },
|
||||
user: { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://proxy', headers: { 'X-Team': 'a' } }, zombie: {} } },
|
||||
applies: 'live',
|
||||
secrets: [{ path: ['token'], set: false }, { path: ['providers', 'openai', 'apiKey'], set: false }],
|
||||
secrets: [],
|
||||
revision: 0,
|
||||
},
|
||||
]
|
||||
@@ -262,22 +259,17 @@ describe('ModelsSection', () => {
|
||||
expect(screen.queryByLabelText(en.keyInput)).toBeNull()
|
||||
})
|
||||
|
||||
it('decides setup need from the joined credential state and literal-key sidecar', () => {
|
||||
it('decides setup need from the joined credential state', () => {
|
||||
const entry = { provider: 'p', displayName: 'p', settingsNs: 'llm-deepseek', settingsPath: [], active: true }
|
||||
const row = (
|
||||
credential: ProviderRow['credential'],
|
||||
literalApiKeyConfigured = false,
|
||||
): ProviderRow => ({
|
||||
const row = (credential: ProviderRow['credential']): ProviderRow => ({
|
||||
entry,
|
||||
configured: true,
|
||||
removable: false,
|
||||
apiKeyEnv: 'X',
|
||||
credential,
|
||||
literalApiKeyConfigured,
|
||||
})
|
||||
expect(needsSetup(row(undefined))).toBe(true)
|
||||
expect(needsSetup(row({ configured: true, writable: true }))).toBe(false)
|
||||
expect(needsSetup(row(undefined, true))).toBe(false)
|
||||
const nested = { ...row(undefined), entry: { ...entry, settingsPath: ['providers', 'x'] } }
|
||||
expect(needsSetup(nested)).toBe(false)
|
||||
})
|
||||
@@ -294,9 +286,7 @@ describe('ModelsSection', () => {
|
||||
expect(providerTargetLabel(OPENAI_TARGET)).toBe('openai')
|
||||
})
|
||||
|
||||
it('names only the fields the card can see, so an unseen secret survives', () => {
|
||||
// `before` is the REDACTED subtree: a stored literal apiKey is in neither
|
||||
// side, so no op mentions it and the seam leaves it alone.
|
||||
it('names only changed fields instead of rebuilding the section', () => {
|
||||
expect(pathOps(['providers', 'openai'], { baseURL: 'https://old', reasoning: 'high' }, { reasoning: 'high' }))
|
||||
.toEqual([{ op: 'unset', path: ['providers', 'openai', 'baseURL'] }])
|
||||
expect(pathOps([], { b: 1 }, { b: 2, d: 3 }))
|
||||
@@ -724,8 +714,7 @@ describe('ModelsSection', () => {
|
||||
})
|
||||
|
||||
it('clears an inherited override with an unset op, never a whole-section replace', async () => {
|
||||
// The data-loss shape: the old path rebuilt the section from the REDACTED
|
||||
// user layer and replaced it wholesale, deleting any stored literal key.
|
||||
// The old path rebuilt the whole user section to clear one inherited field.
|
||||
const { replace, update, mutate } = await mountSection()
|
||||
fireEvent.click(screen.getByText(en.customized))
|
||||
const url = screen.getByLabelText<HTMLInputElement>(en.baseUrl)
|
||||
@@ -798,9 +787,7 @@ describe('ModelsSection', () => {
|
||||
fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
|
||||
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
|
||||
// Only the edited field travels: apiKeyEnv and headers were already stored
|
||||
// with these values, so no op restates them — and the profile's stored
|
||||
// literal apiKey, absent from the redacted view the card read, is named by
|
||||
// nothing at all.
|
||||
// with these values, so no op restates them.
|
||||
expect(mutate.mock.calls[0]?.[0]).toEqual({
|
||||
ns: 'llm-pi-ai',
|
||||
ops: [{ op: 'set', path: ['providers', 'openai', 'baseURL'], value: 'https://proxy/v2' }],
|
||||
@@ -1132,8 +1119,8 @@ describe('ModelsSection', () => {
|
||||
})
|
||||
|
||||
it('removes by unsetting the profile path, never by rebuilding the section', async () => {
|
||||
// The section rebuild is what dropped stored literal secrets: this page
|
||||
// only ever holds the redacted descriptor, so the removal names the path.
|
||||
// The page only needs to name the profile path; rebuilding the section
|
||||
// would widen the write for no benefit.
|
||||
const { face, mutate, replace, controller } = await mountSection()
|
||||
await removeProviderProfile(
|
||||
face as unknown as Parameters<typeof removeProviderProfile>[0],
|
||||
|
||||
@@ -28,7 +28,6 @@ function harness(options: {
|
||||
providerActive?: boolean
|
||||
settingsNamespace?: boolean
|
||||
apiKeyEnv?: string | null
|
||||
literal?: boolean
|
||||
configured?: () => boolean
|
||||
credential?: { source?: string; writable: boolean }
|
||||
describeFailure?: string
|
||||
@@ -66,7 +65,7 @@ function harness(options: {
|
||||
? {}
|
||||
: { apiKeyEnv: options.apiKeyEnv ?? 'DEEPSEEK_API_KEY' },
|
||||
applies: 'live' as const,
|
||||
secrets: [{ path: ['apiKey'], set: options.literal === true }],
|
||||
secrets: [],
|
||||
revision: 0,
|
||||
}],
|
||||
})),
|
||||
@@ -153,11 +152,10 @@ describe('DeepSeekOnboardingDialog', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('skips an absent adapter and already-configured literal or environment credentials', async () => {
|
||||
it('skips an absent adapter and an already-configured environment credential', async () => {
|
||||
for (const h of [
|
||||
harness({ provider: false }),
|
||||
harness({ providerSettingsNs: '' }),
|
||||
harness({ literal: true, describeFailure: 'credential seam absent' }),
|
||||
harness({ configured: () => true, credential: { source: 'env', writable: false } }),
|
||||
]) {
|
||||
const view = render(<DeepSeekOnboardingDialog {...h.props} />)
|
||||
|
||||
@@ -19,7 +19,6 @@ function row(overrides: Partial<ProviderRow> = {}): ProviderRow {
|
||||
removable: false,
|
||||
apiKeyEnv: 'DEEPSEEK_API_KEY',
|
||||
credential: missingCredential,
|
||||
literalApiKeyConfigured: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
@@ -64,13 +63,6 @@ describe('deepSeekReadiness', () => {
|
||||
}))).toEqual({ kind: 'configured' })
|
||||
})
|
||||
|
||||
it('accepts the redacted literal-key sidecar before judging the credential domain', () => {
|
||||
expect(deepSeekReadiness(state({
|
||||
credentialError: 'credentials service absent',
|
||||
rows: [row({ literalApiKeyConfigured: true, credential: undefined })],
|
||||
}))).toEqual({ kind: 'configured' })
|
||||
})
|
||||
|
||||
it('turns missing capabilities and inconsistent descriptors into diagnostics', () => {
|
||||
expect(deepSeekReadiness(state({ status: 'error', error: 'settings down' }))).toEqual({
|
||||
kind: 'unavailable',
|
||||
|
||||
@@ -25,7 +25,7 @@ const NAMESPACES = [
|
||||
value: { apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://base' },
|
||||
base: { baseURL: 'https://base' },
|
||||
applies: 'live' as const,
|
||||
secrets: [{ path: ['apiKey'], set: false }],
|
||||
secrets: [],
|
||||
revision: 0,
|
||||
},
|
||||
{
|
||||
@@ -85,7 +85,6 @@ describe('ModelsSettingsStore', () => {
|
||||
removable: false,
|
||||
apiKeyEnv: 'DEEPSEEK_API_KEY',
|
||||
credential: { configured: false, writable: true },
|
||||
literalApiKeyConfigured: false,
|
||||
})
|
||||
expect(byProvider.get('openai')).toMatchObject({
|
||||
configured: true,
|
||||
@@ -131,30 +130,6 @@ describe('ModelsSettingsStore', () => {
|
||||
expect(store.store.getSnapshot().credentialError).toBe('credential transport refusal')
|
||||
})
|
||||
|
||||
it('joins a configured literal key from the redacted secret sidecar', async () => {
|
||||
const { face } = api({
|
||||
describeSettings: () => Promise.resolve(ok({
|
||||
writable: true,
|
||||
hasDocument: false,
|
||||
namespaces: [{
|
||||
...NAMESPACES[0],
|
||||
secrets: [
|
||||
{ path: ['apiKey', 'nested'], set: true },
|
||||
{ path: ['different'], set: true },
|
||||
{ path: ['apiKey'], set: true },
|
||||
],
|
||||
}] as never,
|
||||
})),
|
||||
providers: () => Promise.resolve(ok({ providers: [DIRECTORY[0]] as never })),
|
||||
})
|
||||
const store = new ModelsSettingsStore(face)
|
||||
await store.load()
|
||||
expect(store.store.getSnapshot().rows[0]).toMatchObject({
|
||||
literalApiKeyConfigured: true,
|
||||
apiKeyEnv: 'DEEPSEEK_API_KEY',
|
||||
})
|
||||
})
|
||||
|
||||
it('surfaces a directory failure and keeps the last good rows', async () => {
|
||||
const { face } = api()
|
||||
const store = new ModelsSettingsStore(face)
|
||||
|
||||
@@ -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 packages/credentials/credentials-local/README.md
|
||||
README.md: 7d541d42efc942310e9a4066a9edadedb608ef0b
|
||||
README.zh.md: 6a3d697551c8607bbab59a81c84eb725bf6191eb
|
||||
README.md: 8e95a890a8e38172cf8984653a01c59570f0061a
|
||||
README.zh.md: 04ad07ae4e703ab0416d1d8f1bb6a6ff90adf337
|
||||
@@ -2,37 +2,56 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
File-backed [credentials](../credentials/README.md) provider: two layers, one honest precedence.
|
||||
File-backed [credentials](../credentials/README.md) provider: four layers, one honest precedence.
|
||||
|
||||
| Layer | Source id | Writable | Wins |
|
||||
|---|---|---|---|
|
||||
| Live process environment | `env` | no | always |
|
||||
| `$DSH_HOME/.env` document | `file` | yes (`set`/`unset`) | otherwise |
|
||||
| Inherited process environment | `env` | no | always |
|
||||
| `$DSH_HOME/.credentials.yaml` document | `file` | yes (`set`/`unset`) | over both `.env` layers |
|
||||
| `<invocation cwd>/.env` | `project-env` | not here | over the user `.env` |
|
||||
| `$DSH_HOME/.env` | `user-env` | not here | otherwise |
|
||||
|
||||
The environment wins because a launch-time override (`DEEPSEEK_API_KEY=… dsh`, CI secrets, a dev shell sourcing the repo `.env`) is operator intent for this run — and because it cannot be edited from inside, it must be *visibly* read-only: `describe()` reports `source: 'env', writable: false`, and `set`/`unset` reject instead of writing a change the reader would never see. Resolution reads `process.env` live and never writes it back.
|
||||
The launching environment wins because a per-run override (`DEEPSEEK_API_KEY=… dsh`, a CI secret, a container `-e`) is operator intent for this run — and because it cannot be edited from inside, it must be *visibly* read-only: `describe()` reports `source: 'env', writable: false`, and `set`/`unset` reject instead of writing a change the reader would never see.
|
||||
|
||||
Everything below it loses to the managed store, so a key written by the Models page takes effect immediately even when an older key sits in a `.env`. Those two layers still resolve when nothing is stored, and `describe()` names them `project-env` or `user-env` with `writable: true` — storing a key replaces them as the effective source.
|
||||
|
||||
Under the product CLI, resolution reads the launcher's frozen [environment snapshot](../../util/environment/README.md) rather than `process.env`: only the snapshot can say whether a value came from the launching shell or from a file. A composition the product CLI did not boot has the inherited environment as its only layer, which keeps embedders on the semantics they already had.
|
||||
|
||||
## Config
|
||||
|
||||
| Field | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `path` | `<harness home>/.env` | Credentials document location. |
|
||||
| `path` | `<harness home>/.credentials.yaml` | Credentials document location. |
|
||||
| `dshHome` | `$DSH_HOME` or `~/.dsh` | Harness home used when `path` is omitted. |
|
||||
| `watch` | `true` | Hot-publish external edits. |
|
||||
| `debounceMs` | `100` | Watcher write-settle window. |
|
||||
|
||||
## The document
|
||||
|
||||
dotenv format, parsed with `dotenv` and edited by a physical-line editor that preserves every byte it does not own: `set` rewrites the first assignment of its key in place with that line's own ending (dropping later duplicates, which dotenv's last-wins reading would otherwise let override the edit), `unset` removes only the owning line, and comments, unrelated lines, CRLF endings, and the continuation lines of another key's quoted multi-line value all survive verbatim. Every write first re-reads the document under the cross-process writer lock of [`dsh-atomic-write`](../../util/atomic-write/README.md) and publishes anything it had not observed, then commits atomically with mode `0600` under an owner-only (`0700`) directory — so a concurrent writer or an external edit inside the watcher's debounce window is folded in rather than overwritten.
|
||||
A YAML mapping of credential reference to value, and nothing else:
|
||||
|
||||
Values are rendered in the narrowest style dotenv reads back verbatim — bare, then single-quoted (fully literal), then double-quoted (only without backslashes, which double-quote reading expands). A value no style can represent, and any entry that already spans multiple physical lines, fails loud instead of being corrupted silently. An empty stored value is absent, per the seam rule.
|
||||
```yaml
|
||||
DEEPSEEK_API_KEY: sk-…
|
||||
OPENAI_API_KEY: sk-…
|
||||
```
|
||||
|
||||
The document holds credentials only, so every deviation is a rejection rather than a skipped entry — a silently ignored key would read as "the secret I stored has no effect". 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 warn-and-keep-the-last-good-snapshot on a live reload. There is no `version` field and no wrapper level; the format is the mapping.
|
||||
|
||||
Writes patch the parsed document rather than rebuilding it, so comments and the formatting of every untouched entry survive. A comment directly above an entry is that entry's annotation and is removed with it. Every write first re-reads the document under the cross-process writer lock of [`dsh-atomic-write`](../../util/atomic-write/README.md) and publishes anything it had not observed, then commits atomically with mode `0600` under an owner-only (`0700`) directory — so a concurrent writer or an external edit inside the watcher's debounce window is folded in rather than overwritten. An on-disk document that no longer parses fails the write instead of overwriting content the provider could not understand.
|
||||
|
||||
Any string value round-trips, multi-line values included, so no entry is unwritable for want of a quoting style. An empty stored value is absent, per the seam rule — which is why an empty string in the document is rejected outright: `unset` removes a key, it does not blank it.
|
||||
|
||||
## Permissions
|
||||
|
||||
The provider creates the directory `0700` and creates or atomically replaces the document `0600`. It holds what it *reads* to that same bound: on POSIX a document carrying any group or other permission bit fails before its contents are parsed — at boot and on every reload — and the error names the `chmod 600` repair. Windows has no mode to inspect, so the check is skipped there rather than faked.
|
||||
|
||||
## Hot reload
|
||||
|
||||
External edits publish `credentials/updated` per changed reference after the snapshot is replaced **wholesale** — an entry deleted on disk never lingers in memory. The provider's own writes are recognized by content and publish exactly their one commit event. An unreadable document at runtime keeps the last good snapshot and warns; an absent file is an empty store; an unreadable file at boot fails loud. Keys that are not POSIX identifiers are preserved file content the seam cannot address.
|
||||
External edits publish `credentials/updated` per changed reference after the snapshot is replaced **wholesale** — an entry deleted on disk never lingers in memory. The provider's own writes are recognized by content and publish exactly their one commit event. An unreadable or invalid document at runtime keeps the last good snapshot and warns; an absent file is an empty store; an unreadable or invalid file at boot fails loud.
|
||||
|
||||
## Security boundary
|
||||
|
||||
The document is `0600` under a `0700` directory, which stops other OS users — **not** the model. Tool processes (bash, the filesystem tools) run as the same user, and the shipped `workspace-write` file policy confines mutations rather than reads, so they can read this file exactly like any other file the user owns; no sandbox mode singles it out. What the harness does hold to is narrower: it never hands the model a resolved path to the document, and never loads it into the process environment (see [app-boot's Harness-home layers](../../ui/app-boot/README.md#profiles)), so reaching the value takes a deliberate read of a path the agent was not given.
|
||||
The document is `0600` under a `0700` directory, which stops other OS users — **not** the model. Tool processes (bash, the filesystem tools) run as the same user, and the shipped `workspace-write` file policy confines mutations rather than reads, so they can read this file exactly like any other file the user owns; no sandbox mode singles it out. What the harness does hold to is narrower: it never hands the model a resolved path to the document, and never loads it into the process environment — unlike `$DSH_HOME/.env`, which is the user's ordinary environment layer (see [app-boot's Harness-home layers](../../ui/app-boot/README.md#profiles)) — so reaching the value takes a deliberate read of a path the agent was not given.
|
||||
|
||||
That is discretion, not a boundary. A deployment that must keep provider keys away from its own agent cannot get there with file permissions; an OS-keychain provider — a store the model's processes cannot read at all — is the deferred answer and belongs beside this provider as a sibling package.
|
||||
|
||||
@@ -46,9 +65,7 @@ No direct invalidation; credentials never enter a request prefix.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Multi-line entries refuse `set`/`unset`** — the line editor will not rewrite an entry it would corrupt; `describe` reports them `writable: false` and edits must go to the file directly.
|
||||
- **Same-reference concurrent writes are last-write-wins** — the writer lock and the read-modify-write keep concurrent writers from dropping each other's entries, but two writers editing one reference still resolve to the later write; there is no revision check.
|
||||
- **A same-UID process can read the document** — see [Security boundary](#security-boundary): the file-effect sandbox modes do not deny reads, and an OS-keychain provider is deferred.
|
||||
- **Unrepresentable values fail loud** — control characters, or a mix of both quote styles with backslashes, cannot round-trip the dotenv line format.
|
||||
- **Environment changes are invisible** — `process.env` is read live per resolution, but no event can announce a change there.
|
||||
- **Environment changes are invisible** — the snapshot is frozen at launch, so a variable exported after startup reaches neither resolution nor `describe`; changing an environment-sourced credential takes a restart.
|
||||
- **Atomic, not crash-durable** — inherited from `dsh-atomic-write`; the store re-reads on boot.
|
||||
@@ -2,37 +2,56 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
文件型[凭据](../credentials/README.md)提供方:两层来源,一条诚实的优先级。
|
||||
文件型[凭据](../credentials/README.md)提供方:四层来源,一条诚实的优先级。
|
||||
|
||||
| 层 | 来源 id | 可写 | 优先 |
|
||||
|---|---|---|---|
|
||||
| 当前进程环境 | `env` | 否 | 始终优先 |
|
||||
| `$DSH_HOME/.env` 文档 | `file` | 是(`set`/`unset`) | 其余情况 |
|
||||
| 继承的进程环境 | `env` | 否 | 始终优先 |
|
||||
| `$DSH_HOME/.credentials.yaml` 文档 | `file` | 是(`set`/`unset`) | 高于两个 `.env` 层 |
|
||||
| `<invocation cwd>/.env` | `project-env` | 不在此处 | 高于用户 `.env` |
|
||||
| `$DSH_HOME/.env` | `user-env` | 不在此处 | 其余情况 |
|
||||
|
||||
环境优先,因为启动时覆盖(`DEEPSEEK_API_KEY=… dsh`、CI 机密、加载了仓库 `.env` 的开发 shell)代表本次运行的操作者意图——而它无法从进程内部修改,就必须*可见地*只读:`describe()` 报告 `source: 'env', writable: false`,`set`/`unset` 直接拒绝,而不是写下一个读取方永远看不到的变更。解析实时读取 `process.env`,绝不写回。
|
||||
启动环境优先,因为按次覆盖(`DEEPSEEK_API_KEY=… dsh`、CI 机密、容器 `-e`)代表本次运行的操作者意图——而它无法从进程内部修改,就必须*可见地*只读:`describe()` 报告 `source: 'env', writable: false`,`set`/`unset` 直接拒绝,而不是写下一个读取方永远看不到的变更。
|
||||
|
||||
它之下的一切都输给受管存储,因此 Models 页写入的密钥会立即生效,即使某个 `.env` 里还留着更旧的密钥。没有存储任何东西时这两层仍会解析,`describe()` 会把来源报告为 `project-env` 或 `user-env` 且 `writable: true`——存入一个密钥就会取代它们成为生效来源。
|
||||
|
||||
在产品 CLI(命令行界面)下,解析读取的是启动器冻结的[环境快照](../../util/environment/README.md)而不是 `process.env`:只有快照才说得清某个值来自启动 shell 还是来自某个文件。并非由产品 CLI 启动的组合只有继承环境这一层,这让嵌入方保持它们原有的语义。
|
||||
|
||||
## 配置
|
||||
|
||||
| 字段 | 默认值 | 含义 |
|
||||
|---|---|---|
|
||||
| `path` | `<harness home>/.env` | 凭据文档位置。 |
|
||||
| `path` | `<harness home>/.credentials.yaml` | 凭据文档位置。 |
|
||||
| `dshHome` | `$DSH_HOME` 或 `~/.dsh` | `path` 缺省时使用的 harness home。 |
|
||||
| `watch` | `true` | 热发布外部编辑。 |
|
||||
| `debounceMs` | `100` | watcher 写入稳定窗口。 |
|
||||
|
||||
## 文档本身
|
||||
|
||||
dotenv 格式,用 `dotenv` 解析;写回用物理行级编辑器,保留一切不属于本次编辑的字节:`set` 原位改写该键的第一条赋值行、沿用该行自身的行尾(丢弃后续重复行——dotenv 按最后一条生效,重复行会反过来覆盖这次编辑),`unset` 只删除所属行,注释、无关行、CRLF 行尾,以及另一个键的带引号多行值的续行,都逐字保留。每次写入都先在 [`dsh-atomic-write`](../../util/atomic-write/README.md) 的跨进程写锁下重读文档、把此前未观察到的一切发布出去,再在仅属主可访问(`0700`)的目录下以 `0600` 权限原子提交——因此并发写入者、或落在 watcher 防抖窗口内的外部编辑会被并入,而不是被覆盖。
|
||||
一个从凭据引用到值的 YAML mapping,除此之外别无他物:
|
||||
|
||||
值按 dotenv 能逐字读回的最窄样式渲染——裸值,其次单引号(完全字面),再次双引号(仅限无反斜杠,双引号读取会展开转义)。任何样式都无法表示的值,以及已经跨越多个物理行的条目,都会明确报错而不是被静默破坏。空的存储值等于不存在(seam 规则)。
|
||||
```yaml
|
||||
DEEPSEEK_API_KEY: sk-…
|
||||
OPENAI_API_KEY: sk-…
|
||||
```
|
||||
|
||||
该文档只存放凭据,因此任何偏离都是拒绝,而不是跳过某个条目——被静默忽略的键读起来就是「我存进去的密钥没有生效」。非 mapping 的根、非 POSIX 标识符的键、非字符串值、空字符串、重复键以及格式错误的 YAML 全部失败:启动时明确报错,运行期热重载则告警并保留最后可用快照。没有 `version` 字段,也没有包装层;格式就是这个 mapping。
|
||||
|
||||
写入是对已解析文档打补丁而不是重建,因此注释与所有未触及条目的排版都会保留。直接位于某条目上方的注释属于该条目的注解,会随它一起删除。每次写入都先在 [`dsh-atomic-write`](../../util/atomic-write/README.md) 的跨进程写锁下重读文档、把此前未观察到的一切发布出去,再在仅属主可访问(`0700`)的目录下以 `0600` 权限原子提交——因此并发写入者、或落在 watcher 防抖窗口内的外部编辑会被并入,而不是被覆盖。磁盘上已经无法解析的文档会让写入失败,而不是覆盖提供方读不懂的内容。
|
||||
|
||||
任何字符串值都能往返,包括多行值,因此不会再有条目因为缺少可用引号样式而不可写。空的存储值等于不存在(seam 规则)——这也正是文档中的空字符串被直接拒绝的原因:`unset` 删除键,而不是把它置空。
|
||||
|
||||
## 权限
|
||||
|
||||
提供方以 `0700` 创建目录,以 `0600` 创建或原子替换文档。它对*读取*同样守住这条界线:在 POSIX 上,只要文档带有任何 group 或 other 权限位,就会在解析其内容之前失败——启动时与每次 reload 都检查——并在错误里给出 `chmod 600` 的修复命令。Windows 没有可检查的 mode,因此在那里跳过该检查而不是伪造它。
|
||||
|
||||
## 热重载
|
||||
|
||||
外部编辑在快照**整体替换**后按变更引用逐个发布 `credentials/updated`——磁盘上删掉的条目绝不在内存滞留。提供方自己的写入按内容识别,只发布属于该次提交的一个事件。运行期文档不可读时保留最后可用快照并告警;文件不存在即空存储;启动时不可读则明确报错。非 POSIX 标识符的键属于被保留的文件内容,seam 无法寻址。
|
||||
外部编辑在快照**整体替换**后按变更引用逐个发布 `credentials/updated`——磁盘上删掉的条目绝不在内存滞留。提供方自己的写入按内容识别,只发布属于该次提交的一个事件。运行期文档不可读或无效时保留最后可用快照并告警;文件不存在即空存储;启动时不可读或无效则明确报错。
|
||||
|
||||
## 安全边界
|
||||
|
||||
文档在 `0700` 目录下以 `0600` 权限存放,这挡得住其他 OS 用户,**挡不住**模型。工具进程(bash、文件系统工具)以同一用户身份运行,而已交付的 `workspace-write` 文件策略限制的是修改而非读取,因此它们读这个文件与读该用户拥有的任何其他文件毫无二致;也没有任何沙箱模式会把它单独挑出来。harness 真正守住的更窄:它绝不把该文档的解析后路径交给模型,也绝不把它载入进程环境(见 [app-boot 的 Harness home 各层](../../ui/app-boot/README.md#profiles)),因此要拿到这个值,需要刻意去读一条并未交给 agent(智能体)的路径。
|
||||
文档在 `0700` 目录下以 `0600` 权限存放,这挡得住其他 OS 用户,**挡不住**模型。工具进程(bash、文件系统工具)以同一用户身份运行,而已交付的 `workspace-write` 文件策略限制的是修改而非读取,因此它们读这个文件与读该用户拥有的任何其他文件毫无二致;也没有任何沙箱模式会把它单独挑出来。harness 真正守住的更窄:它绝不把该文档的解析后路径交给模型,也绝不把它载入进程环境——这与用户的普通环境层 `$DSH_HOME/.env` 不同(见 [app-boot 的 Harness home 各层](../../ui/app-boot/README.md#profiles))——因此要拿到这个值,需要刻意去读一条并未交给 agent(智能体)的路径。
|
||||
|
||||
这是审慎,不是边界。必须让提供方密钥远离自身 agent 的部署无法靠文件权限做到;OS 钥匙串提供方——一个模型的进程根本读不到的存储——才是延后的答案,它应当作为平级包与本提供方并列。
|
||||
|
||||
@@ -46,9 +65,7 @@ dotenv 格式,用 `dotenv` 解析;写回用物理行级编辑器,保留一
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **多行条目拒绝 `set`/`unset`**——行编辑器不改写会被它破坏的条目;`describe` 把它们报为 `writable: false`,编辑必须直接落到文件上。
|
||||
- **同一引用的并发写入是后写胜出**——写锁加读-改-写让并发写入者不会丢掉彼此的条目,但两个写入者编辑同一个引用时仍以较后的写入为准;没有修订检查。
|
||||
- **同 UID 进程可以读取该文档**——见[安全边界](#security-boundary):文件效果沙箱模式不会拒绝读取,OS 钥匙串提供方仍是延后项。
|
||||
- **无法表示的值明确报错**——控制字符,或同时混用两种引号又含反斜杠的值,无法在 dotenv 行格式中往返。
|
||||
- **环境变化不可见**——每次解析实时读取 `process.env`,但那里的变化不可能发出事件。
|
||||
- **环境变化不可见**:快照在启动时冻结,因此启动之后 export 的变量既不会进入解析,也不会进入 `describe`;要更换来自环境的凭据需要重启。
|
||||
- **原子但不具备崩溃持久性**——继承自 `dsh-atomic-write`;存储在启动时重新读取。
|
||||
@@ -27,18 +27,20 @@
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-atomic-write": "^0.0.1",
|
||||
"@deepseek-ai/dsh-credentials": "^0.0.1",
|
||||
"@deepseek-ai/dsh-environment": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-paths": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"chokidar": "^4.0.3",
|
||||
"dotenv": "^17.2.0",
|
||||
"schemastery": "^3.18.0"
|
||||
"schemastery": "^3.18.0",
|
||||
"yaml": "^2.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-atomic-write": "workspace:^",
|
||||
"@deepseek-ai/dsh-credentials": "workspace:^",
|
||||
"@deepseek-ai/dsh-environment": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-paths": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
|
||||
@@ -1,30 +1,58 @@
|
||||
/**
|
||||
* File-backed credentials provider layering the live process environment over
|
||||
* a `$DSH_HOME/.env` document. The environment is authoritative and read-only
|
||||
* (a launch-time override must win, and must be visibly read-only rather than
|
||||
* silently shadow writes); the file is the provider-managed writable source:
|
||||
* every write re-reads the document under a cross-process writer lock before
|
||||
* rewriting only its own line — preserving every other byte, physical line
|
||||
* endings and quoted multi-line values included — external edits hot-publish
|
||||
* through the seam, and each reload replaces the snapshot wholesale so a
|
||||
* deleted entry never lingers in memory.
|
||||
* File-backed credentials provider over `$DSH_HOME/.credentials.yaml`, layered
|
||||
* against the environment by how much each layer is trusted:
|
||||
*
|
||||
* ```text
|
||||
* inherited process environment (read-only, wins)
|
||||
* > $DSH_HOME/.credentials.yaml (provider-managed, writable)
|
||||
* > <invocation cwd>/.env (read-only fallback)
|
||||
* > $DSH_HOME/.env (read-only fallback)
|
||||
* ```
|
||||
*
|
||||
* The inherited environment wins because `DEEPSEEK_API_KEY=… dsh`, a CI
|
||||
* secret, or a container `-e` is this run's explicit intent; it cannot be
|
||||
* edited from inside, so it must be *visibly* read-only rather than silently
|
||||
* shadow writes. Everything below it loses to the managed store, so a key the
|
||||
* Models page writes takes effect immediately even when an older key sits in
|
||||
* the user's `.env`.
|
||||
*
|
||||
* The invoking project may supply a key, because the product trusts the
|
||||
* project it is launched in. It ranks below the managed store, so a key stored
|
||||
* through the Models page is never displaced by one a checkout happens to carry.
|
||||
*
|
||||
* The file is the provider-managed writable source: every write re-reads the
|
||||
* document under a cross-process writer lock before patching only its own key
|
||||
* — comments and the formatting of every untouched entry survive — external
|
||||
* edits hot-publish through the seam, and each reload replaces the snapshot
|
||||
* wholesale so a deleted entry never lingers in memory.
|
||||
*
|
||||
* The document holds nothing but credentials, which is why it is a strict
|
||||
* `CredentialRef`-to-string mapping rather than a dotenv file: a store the
|
||||
* Harness owns and never materializes into the environment cannot also serve
|
||||
* as the user's environment layer, and conflating the two is what made a
|
||||
* non-secret in the old `$DSH_HOME/.env` silently unreachable.
|
||||
* @module @deepseek-ai/dsh-credentials-local
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { watch as chokidarWatch } from 'chokidar'
|
||||
import { mkdir, readFile } from 'node:fs/promises'
|
||||
import { mkdir, readFile, stat } from 'node:fs/promises'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { parse } from 'dotenv'
|
||||
import { Document, parseDocument, type YAMLError } from 'yaml'
|
||||
import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
|
||||
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
import { environmentOf } from '@deepseek-ai/dsh-environment'
|
||||
import { Credentials, credentialRef } from '@deepseek-ai/dsh-credentials'
|
||||
import type { CredentialInfo, CredentialRef, ResolvedCredential } from '@deepseek-ai/dsh-credentials'
|
||||
import type { EnvironmentEntry } from '@deepseek-ai/dsh-environment'
|
||||
|
||||
/** Basename of the credentials document inside the harness home. */
|
||||
export const CREDENTIALS_FILENAME = '.credentials.yaml'
|
||||
|
||||
/** Plugin config: file location and hot-reload behavior. */
|
||||
export interface Config {
|
||||
/** Credentials document path; defaults to `.env` under the harness home. */
|
||||
/** Credentials document path; defaults to `.credentials.yaml` under the harness home. */
|
||||
path?: string
|
||||
/** Harness home used when `path` is omitted; defaults to `$DSH_HOME` or `~/.dsh`. */
|
||||
dshHome?: string
|
||||
@@ -43,146 +71,135 @@ interface ResolvedSpec {
|
||||
|
||||
/**
|
||||
* Resolve the runtime spec from plugin config: an explicit `path` wins,
|
||||
* otherwise the document lives at `<harness home>/.env`.
|
||||
* otherwise the document lives at `<harness home>/.credentials.yaml`.
|
||||
* @param config - raw plugin config.
|
||||
* @returns the resolved file location and watch behavior.
|
||||
*/
|
||||
export function resolveSpec(config: Config): ResolvedSpec {
|
||||
return {
|
||||
filename: resolve(config.path ?? join(resolveDshHome(config.dshHome), '.env')),
|
||||
filename: resolve(config.path ?? join(resolveDshHome(config.dshHome), CREDENTIALS_FILENAME)),
|
||||
watch: config.watch ?? true,
|
||||
debounceMs: config.debounceMs ?? 100,
|
||||
}
|
||||
}
|
||||
|
||||
/** Permission bits outside the owner; a credentials document must have none of them. */
|
||||
const GROUP_OTHER_BITS = 0o077
|
||||
|
||||
/**
|
||||
* Reject a credentials document other OS users can read, before its contents
|
||||
* are read at all. The provider creates and replaces the file at `0600`, but a
|
||||
* hand-written or externally generated one carries whatever umask produced it,
|
||||
* and silently serving secrets out of a world-readable file would make the
|
||||
* mode the provider promises meaningless.
|
||||
*
|
||||
* POSIX only: Windows has no mode to inspect — its ACLs are not expressible
|
||||
* here — so the check is skipped rather than faked, and the file's protection
|
||||
* there is whatever the create and replace APIs express.
|
||||
* @param filename - absolute path of the document.
|
||||
* @throws when the file exists with group or other permission bits set.
|
||||
*/
|
||||
async function assertOwnerOnly(filename: string): Promise<void> {
|
||||
/* v8 ignore next -- native Windows coverage exercises the skip; POSIX covers the check */
|
||||
if (process.platform === 'win32') return
|
||||
let mode: number
|
||||
try {
|
||||
mode = (await stat(filename)).mode
|
||||
} catch (error) {
|
||||
if (!isENOENT(error)) throw error
|
||||
return
|
||||
}
|
||||
const offending = mode & GROUP_OTHER_BITS
|
||||
if (offending === 0) return
|
||||
throw new Error(
|
||||
`credentials-local: ${filename} is readable beyond its owner (mode ${(mode & 0o777).toString(8)});`
|
||||
+ ` run "chmod 600 ${filename}" before starting again`,
|
||||
)
|
||||
}
|
||||
|
||||
/** Whether a filesystem error means absence; every non-ENOENT failure must surface. */
|
||||
function isENOENT(error: unknown): boolean {
|
||||
return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT'
|
||||
}
|
||||
|
||||
/** Values that survive a dotenv round-trip without quoting. */
|
||||
const BARE_VALUE = /^[A-Za-z0-9_@%+:,./-]+$/
|
||||
|
||||
/** Whether a value contains C0 control characters (newlines included) no dotenv style reads back. */
|
||||
function hasControlCharacters(value: string): boolean {
|
||||
for (const char of value) {
|
||||
if (char.charCodeAt(0) < 0x20) return true
|
||||
}
|
||||
return false
|
||||
/**
|
||||
* Describe one YAML parse failure without quoting the source. The parser's own
|
||||
* message embeds the offending line, which here holds a secret.
|
||||
* @param error - the parser's error.
|
||||
* @returns the error code with its line and column.
|
||||
*/
|
||||
function describeYamlError(error: YAMLError): string {
|
||||
const at = error.linePos?.[0]
|
||||
/* v8 ignore next -- `prettyErrors` populates linePos on every error; the guard answers its optional type */
|
||||
const where = at === undefined ? '' : ` at line ${String(at.line)}, column ${String(at.col)}`
|
||||
return `${error.code}${where}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one `KEY=value` line in the narrowest style dotenv reads back
|
||||
* verbatim: bare, then single quotes (fully literal), then double quotes
|
||||
* (safe only without backslashes, which double-quote reading expands).
|
||||
* A value no style can represent fails loud instead of corrupting silently.
|
||||
* Parse one credentials document into its entries. The document is a strict
|
||||
* mapping of {@link CredentialRef} to non-empty string: a non-mapping root, a
|
||||
* key that is not a POSIX identifier, a non-string value, and an empty string
|
||||
* are all rejected rather than skipped, because this file holds nothing but
|
||||
* credentials and a silently ignored entry reads as "the key I stored has no
|
||||
* effect". Duplicate keys surface as parser errors. An empty document is an
|
||||
* empty store.
|
||||
* @param text - the document's text.
|
||||
* @param filename - absolute path, quoted in errors.
|
||||
* @returns the parsed entries, keyed by reference.
|
||||
*/
|
||||
function renderLine(ref: CredentialRef, value: string): string {
|
||||
if (BARE_VALUE.test(value)) return `${ref}=${value}`
|
||||
if (hasControlCharacters(value)) {
|
||||
throw new Error(`credentials-local: the value for "${ref}" contains control characters the .env line format cannot represent`)
|
||||
export function parseCredentialsDocument(text: string, filename: string): Map<string, string> {
|
||||
// `prettyErrors` is on only for `linePos`; `error.message` is never used,
|
||||
// because the parser quotes the offending source line and in this document
|
||||
// that line is a secret. Only the code and position leave this function, and
|
||||
// the same rule governs every other diagnostic here — a key name is safe to
|
||||
// print, a value is not.
|
||||
const document = parseDocument(text, { prettyErrors: true, uniqueKeys: true })
|
||||
if (document.errors.length > 0) {
|
||||
throw new Error(`credentials-local: invalid document at ${filename}: ${
|
||||
document.errors.map(describeYamlError).join('; ')}`)
|
||||
}
|
||||
if (!value.includes('\'')) return `${ref}='${value}'`
|
||||
if (!value.includes('"') && !value.includes('\\')) return `${ref}="${value}"`
|
||||
throw new Error(`credentials-local: the value for "${ref}" mixes quoting no .env style can represent; edit the file directly`)
|
||||
}
|
||||
|
||||
/** Split text into physical lines with their terminators attached. */
|
||||
function physicalLines(text: string): string[] {
|
||||
return text.length === 0 ? [] : text.split(/(?<=\n)/)
|
||||
}
|
||||
|
||||
/** One physical line's content without its terminator. */
|
||||
function lineContent(line: string): string {
|
||||
if (line.endsWith('\r\n')) return line.slice(0, -2)
|
||||
if (line.endsWith('\n')) return line.slice(0, -1)
|
||||
return line
|
||||
}
|
||||
|
||||
/** One physical line's terminator (empty on a final unterminated line). */
|
||||
function lineTerminator(line: string): string {
|
||||
return line.slice(lineContent(line).length)
|
||||
}
|
||||
|
||||
/** An assignment line: optional export, a POSIX identifier, `=`, the value part. */
|
||||
const ASSIGNMENT = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/
|
||||
|
||||
/** Quote characters dotenv reads across physical lines. */
|
||||
const MULTILINE_QUOTES = ['\'', '"', '`']
|
||||
|
||||
/**
|
||||
* The quote character an assignment's value part opens without closing on its
|
||||
* own line — the following physical lines are that value's continuation, not
|
||||
* assignments — or `undefined` for a single-line value.
|
||||
*/
|
||||
function opensMultiline(valuePart: string): string | undefined {
|
||||
const trimmed = valuePart.trimStart()
|
||||
const quote = trimmed[0]
|
||||
if (quote === undefined || !MULTILINE_QUOTES.includes(quote)) return undefined
|
||||
const rest = trimmed.slice(1)
|
||||
const body = quote === '"' ? rest.replaceAll('\\"', '') : rest
|
||||
return body.includes(quote) ? undefined : quote
|
||||
}
|
||||
|
||||
/** Whether a continuation line closes the given quote. */
|
||||
function closesQuote(content: string, quote: string): boolean {
|
||||
const body = quote === '"' ? content.replaceAll('\\"', '') : content
|
||||
return body.includes(quote)
|
||||
const root: unknown = document.toJS() ?? {}
|
||||
if (typeof root !== 'object' || root === null || Array.isArray(root)) {
|
||||
throw new TypeError(`credentials-local: ${filename} must be a mapping of credential reference to value`)
|
||||
}
|
||||
const entries = new Map<string, string>()
|
||||
for (const [key, value] of Object.entries(root as Record<string, unknown>)) {
|
||||
// credentialRef throws on anything that is not a POSIX identifier, which
|
||||
// is exactly the constraint a stored reference must satisfy to be
|
||||
// addressable through the seam.
|
||||
credentialRef(key)
|
||||
// The key name is quoted, never the value: a wrong-typed entry is still a
|
||||
// secret the user meant to store.
|
||||
if (typeof value !== 'string') {
|
||||
throw new TypeError(`credentials-local: the value for "${key}" in ${filename} must be a string`)
|
||||
}
|
||||
if (value.length === 0) {
|
||||
throw new Error(`credentials-local: the value for "${key}" in ${filename} is empty; remove the key instead`)
|
||||
}
|
||||
entries.set(key, value)
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace, insert, or delete one reference's assignment while preserving
|
||||
* every other byte: untouched lines keep their exact content and terminators
|
||||
* (CRLF included), and the physical lines inside another key's quoted
|
||||
* multi-line value are never mistaken for assignments. The first matching
|
||||
* assignment is rewritten in place with its own line ending; later duplicates
|
||||
* drop (dotenv reads the last one, so a surviving duplicate would override
|
||||
* the edit); an insert appends in the document's dominant ending style.
|
||||
* Render the next document text with one reference set or deleted. Editing
|
||||
* the parsed document rather than rebuilding it keeps comments and the
|
||||
* formatting of every untouched entry; an absent document starts a fresh one.
|
||||
* @param text - the current document text, `undefined` while the file is absent.
|
||||
* @param ref - the reference to write.
|
||||
* @param value - the new value, or `undefined` to delete the key.
|
||||
* @returns the text to persist.
|
||||
*/
|
||||
function upsertLine(text: string | undefined, ref: CredentialRef, rendered: string | undefined): string {
|
||||
const lines = physicalLines(text ?? '')
|
||||
const dominant = lines.some(line => line.endsWith('\r\n')) ? '\r\n' : '\n'
|
||||
const out: string[] = []
|
||||
let placed = false
|
||||
let pendingQuote: string | undefined
|
||||
for (const line of lines) {
|
||||
const content = lineContent(line)
|
||||
if (pendingQuote !== undefined) {
|
||||
// Inside a quoted multi-line value: never an assignment, always kept.
|
||||
if (closesQuote(content, pendingQuote)) pendingQuote = undefined
|
||||
out.push(line)
|
||||
continue
|
||||
}
|
||||
const match = ASSIGNMENT.exec(content)
|
||||
if (match === null) {
|
||||
out.push(line)
|
||||
continue
|
||||
}
|
||||
const [, key, valuePart] = match
|
||||
if (key !== ref) {
|
||||
/* v8 ignore next -- the value group is `(.*)`, which always participates; the fallback only satisfies noUncheckedIndexedAccess */
|
||||
pendingQuote = opensMultiline(valuePart ?? '')
|
||||
out.push(line)
|
||||
continue
|
||||
}
|
||||
// The write path refuses multi-line targets before rendering, so the
|
||||
// matched assignment is single-line and drops or rewrites wholesale.
|
||||
if (rendered !== undefined && !placed) {
|
||||
out.push(`${rendered}${lineTerminator(line) === '' ? dominant : lineTerminator(line)}`)
|
||||
placed = true
|
||||
}
|
||||
}
|
||||
if (rendered !== undefined && !placed) {
|
||||
const last = out[out.length - 1]
|
||||
if (last !== undefined && lineTerminator(last) === '') {
|
||||
out[out.length - 1] = `${last}${dominant}`
|
||||
}
|
||||
out.push(`${rendered}${dominant}`)
|
||||
}
|
||||
return out.join('')
|
||||
function renderDocument(text: string | undefined, ref: CredentialRef, value: string | undefined): string {
|
||||
// `text` only ever caches content that parsed successfully, so this re-parse
|
||||
// for the mutable comment-preserving tree cannot fail.
|
||||
const document = text === undefined ? new Document({}) : parseDocument(text)
|
||||
if (value === undefined) document.deleteIn([ref])
|
||||
else document.setIn([ref], value)
|
||||
return document.toString()
|
||||
}
|
||||
|
||||
/** File-backed credentials provider (`$DSH_HOME/.env`). */
|
||||
/** File-backed credentials provider (`$DSH_HOME/.credentials.yaml`). */
|
||||
export class CredentialsLocal extends Credentials {
|
||||
/* jscpd:ignore-start -- deliberate config-surface and lifecycle symmetry with
|
||||
settings-local (prefer symmetry for parallel values); extracting the shared
|
||||
@@ -225,6 +242,22 @@ export class CredentialsLocal extends Credentials {
|
||||
this.spec = resolveSpec(config)
|
||||
}
|
||||
|
||||
/** The inherited-environment value for a reference, or `undefined` when empty or unset. */
|
||||
private inherited(ref: CredentialRef): string | undefined {
|
||||
const entry = environmentOf(this.ctx).getFrom(ref, ['process'])
|
||||
return entry !== undefined && entry.value.length > 0 ? entry.value : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* The `.env` fallback for a reference — below the managed store, never above
|
||||
* it. The invoking project ranks over the user's home file, matching the
|
||||
* environment layering: the more specific location wins.
|
||||
*/
|
||||
private dotenvFallback(ref: CredentialRef): EnvironmentEntry | undefined {
|
||||
const entry = environmentOf(this.ctx).getFrom(ref, ['project-env', 'user-env'])
|
||||
return entry !== undefined && entry.value.length > 0 ? entry : undefined
|
||||
}
|
||||
|
||||
async* [Service.init](): AsyncGenerator<() => Promise<void> | void, void, void> {
|
||||
yield async () => {
|
||||
// Drain: refuse new operations, then settle the queued ones so disposal
|
||||
@@ -270,24 +303,26 @@ export class CredentialsLocal extends Credentials {
|
||||
}
|
||||
|
||||
override resolve(ref: CredentialRef): Promise<ResolvedCredential | undefined> {
|
||||
const env = process.env[ref]
|
||||
if (env !== undefined && env.length > 0) return Promise.resolve({ value: env, source: 'env' })
|
||||
const inherited = this.inherited(ref)
|
||||
if (inherited !== undefined) return Promise.resolve({ value: inherited, source: 'env' })
|
||||
const stored = this.values.get(ref)
|
||||
if (stored !== undefined && stored.length > 0) return Promise.resolve({ value: stored, source: 'file' })
|
||||
if (stored !== undefined) return Promise.resolve({ value: stored, source: 'file' })
|
||||
const fallback = this.dotenvFallback(ref)
|
||||
if (fallback !== undefined) return Promise.resolve({ value: fallback.value, source: fallback.source })
|
||||
return Promise.resolve(undefined)
|
||||
}
|
||||
|
||||
override describe(ref: CredentialRef): Promise<CredentialInfo> {
|
||||
const env = process.env[ref]
|
||||
if (env !== undefined && env.length > 0) {
|
||||
// Only the inherited environment is unwritable: it is the one layer this
|
||||
// process cannot edit. A user `.env` value is writable in the sense that
|
||||
// matters — storing a key replaces it as the effective one.
|
||||
if (this.inherited(ref) !== undefined) {
|
||||
return Promise.resolve({ configured: true, source: 'env', writable: false })
|
||||
}
|
||||
const stored = this.values.get(ref)
|
||||
if (stored !== undefined && stored.length > 0) {
|
||||
// A quoted multi-line value resolves fine but the line editor refuses to
|
||||
// rewrite it, so writability must say what set() would actually do.
|
||||
return Promise.resolve({ configured: true, source: 'file', writable: !stored.includes('\n') })
|
||||
}
|
||||
if (stored !== undefined) return Promise.resolve({ configured: true, source: 'file', writable: true })
|
||||
const fallback = this.dotenvFallback(ref)
|
||||
if (fallback !== undefined) return Promise.resolve({ configured: true, source: fallback.source, writable: true })
|
||||
return Promise.resolve({ configured: false, writable: true })
|
||||
}
|
||||
|
||||
@@ -350,12 +385,7 @@ export class CredentialsLocal extends Credentials {
|
||||
await this.reconcileFromDisk()
|
||||
const existing = this.values.get(ref)
|
||||
if (value === undefined && existing === undefined) return
|
||||
if (existing !== undefined && existing.includes('\n')) {
|
||||
throw new Error(
|
||||
`credentials-local: "${ref}" is a multi-line entry this line editor would corrupt; edit ${this.spec.filename} directly`,
|
||||
)
|
||||
}
|
||||
const nextText = upsertLine(this.text, ref, value === undefined ? undefined : renderLine(ref, value))
|
||||
const nextText = renderDocument(this.text, ref, value)
|
||||
// 0600: a document holding secrets is never world-readable.
|
||||
await writeFileAtomic(this.spec.filename, nextText, { mode: 0o600, dirMode: 0o700 })
|
||||
this.text = nextText
|
||||
@@ -368,19 +398,27 @@ export class CredentialsLocal extends Credentials {
|
||||
})
|
||||
}
|
||||
|
||||
/** Reject a write the live environment would shadow into apparent no-effect. */
|
||||
/**
|
||||
* Reject a write the inherited environment would shadow into apparent
|
||||
* no-effect. Only that layer can shadow a write: everything else this
|
||||
* provider resolves ranks below the document being written.
|
||||
*/
|
||||
private assertUnshadowed(ref: CredentialRef, verb: 'set' | 'unset'): void {
|
||||
const env = process.env[ref]
|
||||
if (env !== undefined && env.length > 0) {
|
||||
if (this.inherited(ref) !== undefined) {
|
||||
throw new Error(
|
||||
`credentials-local: "${ref}" is supplied read-only by the process environment, so ${verb} would be`
|
||||
+ ' shadowed; change the launching environment instead',
|
||||
`credentials-local: "${ref}" is supplied read-only by the launching environment, so ${verb} would be`
|
||||
+ ' shadowed; unset it in the shell you start dsh from instead',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Boot read: an absent file is an empty store; any other failure is loud. */
|
||||
/**
|
||||
* Boot read: an absent file is an empty store; an invalid one fails the
|
||||
* plugin's activation, because a credentials document that exists but
|
||||
* cannot be trusted must never be treated as "no credentials stored".
|
||||
*/
|
||||
private async loadInitial(): Promise<void> {
|
||||
await assertOwnerOnly(this.spec.filename)
|
||||
let text: string
|
||||
try {
|
||||
text = await readFile(this.spec.filename, 'utf8')
|
||||
@@ -388,8 +426,8 @@ export class CredentialsLocal extends Credentials {
|
||||
if (!isENOENT(error)) throw error
|
||||
return
|
||||
}
|
||||
this.values = parseCredentialsDocument(text, this.spec.filename)
|
||||
this.text = text
|
||||
this.values = new Map(Object.entries(parse(text)))
|
||||
}
|
||||
|
||||
/* jscpd:ignore-start -- same deliberate mirror of settings-local's reload and
|
||||
@@ -415,12 +453,15 @@ export class CredentialsLocal extends Credentials {
|
||||
|
||||
/**
|
||||
* Compare the on-disk text against the cache and publish any difference
|
||||
* into the seam. Absence publishes the empty store; an unreadable file
|
||||
* throws, so each caller picks its policy — a reload warns and keeps the
|
||||
* last good snapshot, a write fails loud. dotenv parsing is lenient by
|
||||
* design and cannot fail.
|
||||
* into the seam. Absence publishes the empty store; an unreadable or
|
||||
* invalid document throws, so each caller picks its policy — a reload warns
|
||||
* and keeps the last good snapshot, a write fails loud rather than
|
||||
* overwriting a document it could not understand.
|
||||
*/
|
||||
private async reconcileFromDisk(): Promise<void> {
|
||||
// Re-checked on every reload and before every write: an external editor or
|
||||
// a restored backup can loosen the mode after boot.
|
||||
await assertOwnerOnly(this.spec.filename)
|
||||
let text: string | undefined
|
||||
try {
|
||||
text = await readFile(this.spec.filename, 'utf8')
|
||||
@@ -429,7 +470,7 @@ export class CredentialsLocal extends Credentials {
|
||||
text = undefined
|
||||
}
|
||||
if (text === this.text || this.isClosed()) return
|
||||
const next = text === undefined ? new Map<string, string>() : new Map(Object.entries(parse(text)))
|
||||
const next = text === undefined ? new Map<string, string>() : parseCredentialsDocument(text, this.spec.filename)
|
||||
const changed = this.changedRefs(this.values, next)
|
||||
this.text = text
|
||||
this.values = next
|
||||
@@ -437,21 +478,12 @@ export class CredentialsLocal extends Credentials {
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
/** Seam-addressable entries whose effective (non-empty) value changed. */
|
||||
/** Entries whose stored value changed; the parser has already proven every key addressable. */
|
||||
private changedRefs(prev: Map<string, string>, next: Map<string, string>): CredentialRef[] {
|
||||
const changed: CredentialRef[] = []
|
||||
for (const key of new Set([...prev.keys(), ...next.keys()])) {
|
||||
const before = prev.get(key)
|
||||
const after = next.get(key)
|
||||
const effectiveBefore = before !== undefined && before.length > 0 ? before : undefined
|
||||
const effectiveAfter = after !== undefined && after.length > 0 ? after : undefined
|
||||
if (effectiveBefore === effectiveAfter) continue
|
||||
try {
|
||||
changed.push(credentialRef(key))
|
||||
} catch (_unaddressableKey) {
|
||||
// A key that is not a POSIX identifier is preserved file content the
|
||||
// seam cannot address, so no observer could ever see it change.
|
||||
}
|
||||
if (prev.get(key) === next.get(key)) continue
|
||||
changed.push(credentialRef(key))
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ describe('write-drain teardown', () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-credentials-drain-'))
|
||||
cleanups.push(() => rm(dir, { recursive: true, force: true }))
|
||||
const ctx = new Context()
|
||||
const fiber = ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false })
|
||||
const fiber = ctx.plugin(CredentialsLocal, { path: join(dir, '.credentials.yaml'), watch: false })
|
||||
await fiber
|
||||
const service = ctx.credentials
|
||||
|
||||
|
||||
@@ -4,9 +4,15 @@ import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
||||
import { createEnvironmentSnapshot, DSH_ENVIRONMENT_KEY } from '@deepseek-ai/dsh-environment'
|
||||
import type { CredentialRef } from '@deepseek-ai/dsh-credentials'
|
||||
import { CredentialsLocal, resolveSpec } from '../src/index.ts'
|
||||
|
||||
/** Credential documents are seeded owner-only, exactly as the provider creates them. */
|
||||
function writeCredentials(file: string, text: string): Promise<void> {
|
||||
return writeFile(file, text, { mode: 0o600 })
|
||||
}
|
||||
|
||||
const KEY = credentialRef('DSH_CRED_TEST')
|
||||
const OTHER = credentialRef('DSH_CRED_OTHER')
|
||||
|
||||
@@ -42,29 +48,29 @@ function updates(ctx: Context): CredentialRef[] {
|
||||
}
|
||||
|
||||
describe('resolveSpec', () => {
|
||||
it('defaults to .env under the harness home with watching on', () => {
|
||||
it('defaults to .credentials.yaml under the harness home with watching on', () => {
|
||||
const spec = resolveSpec({ dshHome: '/custom/home' })
|
||||
expect(spec).toEqual({ filename: resolve('/custom/home/.env'), watch: true, debounceMs: 100 })
|
||||
expect(spec).toEqual({ filename: resolve('/custom/home/.credentials.yaml'), watch: true, debounceMs: 100 })
|
||||
})
|
||||
|
||||
it('lets an explicit path win over the home', () => {
|
||||
const spec = resolveSpec({ path: '/etc/dsh/creds.env', dshHome: '/ignored', watch: false, debounceMs: 5 })
|
||||
expect(spec).toEqual({ filename: resolve('/etc/dsh/creds.env'), watch: false, debounceMs: 5 })
|
||||
const spec = resolveSpec({ path: '/etc/dsh/creds.yaml', dshHome: '/ignored', watch: false, debounceMs: 5 })
|
||||
expect(spec).toEqual({ filename: resolve('/etc/dsh/creds.yaml'), watch: false, debounceMs: 5 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('layering and reads', () => {
|
||||
it('treats an absent file as an empty writable store', async () => {
|
||||
const dir = await tempDir()
|
||||
const ctx = await boot({ path: join(dir, '.env'), watch: false })
|
||||
const ctx = await boot({ path: join(dir, '.credentials.yaml'), watch: false })
|
||||
expect(await ctx.credentials.resolve(KEY)).toBeUndefined()
|
||||
expect(await ctx.credentials.describe(KEY)).toEqual({ configured: false, writable: true })
|
||||
})
|
||||
|
||||
it('serves file entries, including export-prefixed and quoted values', async () => {
|
||||
it('serves file entries alongside comments and quoted values', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.env')
|
||||
await writeFile(path, '# notes\nexport DSH_CRED_TEST=plain\nDSH_CRED_OTHER="with space"\n')
|
||||
const path = join(dir, '.credentials.yaml')
|
||||
await writeCredentials(path, '# notes\nDSH_CRED_TEST: plain\nDSH_CRED_OTHER: "with space"\n')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'plain', source: 'file' })
|
||||
expect(await ctx.credentials.resolve(OTHER)).toEqual({ value: 'with space', source: 'file' })
|
||||
@@ -73,22 +79,22 @@ describe('layering and reads', () => {
|
||||
|
||||
it('lets a non-empty process environment win read-only over the file', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.env')
|
||||
await writeFile(path, 'DSH_CRED_TEST=from-file\n')
|
||||
const path = join(dir, '.credentials.yaml')
|
||||
await writeCredentials(path, 'DSH_CRED_TEST: from-file\n')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
vi.stubEnv('DSH_CRED_TEST', 'from-env')
|
||||
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'from-env', source: 'env' })
|
||||
expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'env', writable: false })
|
||||
})
|
||||
|
||||
it('treats empty values as absent in both layers', async () => {
|
||||
it('treats an empty environment value as absent, falling through to the file', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.env')
|
||||
await writeFile(path, 'DSH_CRED_TEST=\n')
|
||||
const path = join(dir, '.credentials.yaml')
|
||||
await writeCredentials(path, 'DSH_CRED_TEST: stored\n')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
vi.stubEnv('DSH_CRED_TEST', '')
|
||||
expect(await ctx.credentials.resolve(KEY)).toBeUndefined()
|
||||
expect(await ctx.credentials.describe(KEY)).toEqual({ configured: false, writable: true })
|
||||
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'stored', source: 'file' })
|
||||
expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'file', writable: true })
|
||||
})
|
||||
|
||||
it('fails boot loud when the document exists but cannot be read', async () => {
|
||||
@@ -100,110 +106,280 @@ describe('layering and reads', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('line-editing writes', () => {
|
||||
it('appends a missing key to a fresh 0600 document and emits the commit', async () => {
|
||||
describe('layer ladder', () => {
|
||||
// inherited process env > .credentials.yaml > $DSH_HOME/.env, and the
|
||||
// invoking directory's .env supplies no credential at all.
|
||||
async function bootLayered(
|
||||
path: string,
|
||||
layers: Parameters<typeof createEnvironmentSnapshot>[0],
|
||||
): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
ctx.provide(DSH_ENVIRONMENT_KEY, createEnvironmentSnapshot(layers))
|
||||
const fiber = ctx.plugin(CredentialsLocal, { path, watch: false })
|
||||
cleanups.push(async () => { await fiber.dispose() })
|
||||
await fiber
|
||||
return ctx
|
||||
}
|
||||
|
||||
it('lets the stored value beat the user .env, so a UI write takes effect immediately', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.env')
|
||||
const path = join(dir, '.credentials.yaml')
|
||||
await writeCredentials(path, 'DSH_CRED_TEST: stored\n')
|
||||
const ctx = await bootLayered(path, [
|
||||
{ source: 'process', values: {} },
|
||||
{ source: 'user-env', path: '/home/.dsh/.env', values: { DSH_CRED_TEST: 'older-user-env' } },
|
||||
])
|
||||
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'stored', source: 'file' })
|
||||
// The old dead end is gone: a key sitting in the user's .env no longer
|
||||
// makes the stored one unwritable.
|
||||
expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'file', writable: true })
|
||||
await expect(ctx.credentials.set(KEY, 'rotated')).resolves.toBeUndefined()
|
||||
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'rotated', source: 'file' })
|
||||
})
|
||||
|
||||
it('serves the user .env only when nothing is stored', async () => {
|
||||
const dir = await tempDir()
|
||||
const ctx = await bootLayered(join(dir, '.credentials.yaml'), [
|
||||
{ source: 'process', values: {} },
|
||||
{ source: 'user-env', path: '/home/.dsh/.env', values: { DSH_CRED_TEST: 'from-user-env' } },
|
||||
])
|
||||
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'from-user-env', source: 'user-env' })
|
||||
// Writable: storing a key replaces it as the effective one.
|
||||
expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'user-env', writable: true })
|
||||
})
|
||||
|
||||
it('serves the invoking project .env over the user one, but never over the store', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.credentials.yaml')
|
||||
// The product trusts the project it is launched in, so a checkout may
|
||||
// carry its own key — ranked above the user's home file (more specific
|
||||
// wins) and below the managed store, which a stored key must never lose to.
|
||||
const layers = [
|
||||
{ source: 'process' as const, values: {} },
|
||||
{ source: 'project-env' as const, path: '/work/.env', values: { DSH_CRED_TEST: 'from-project' } },
|
||||
{ source: 'user-env' as const, path: '/home/.dsh/.env', values: { DSH_CRED_TEST: 'from-user' } },
|
||||
]
|
||||
const bare = await bootLayered(path, layers)
|
||||
expect(await bare.credentials.resolve(KEY)).toEqual({ value: 'from-project', source: 'project-env' })
|
||||
expect(await bare.credentials.describe(KEY)).toEqual({ configured: true, source: 'project-env', writable: true })
|
||||
|
||||
await writeCredentials(path, 'DSH_CRED_TEST: stored\n')
|
||||
const stored = await bootLayered(path, layers)
|
||||
expect(await stored.credentials.resolve(KEY)).toEqual({ value: 'stored', source: 'file' })
|
||||
})
|
||||
|
||||
it('refuses a document other OS users can read', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.credentials.yaml')
|
||||
await writeFile(path, 'DSH_CRED_TEST: leaked\n', { mode: 0o644 })
|
||||
const ctx = new Context()
|
||||
// Before the contents are read at all: serving secrets out of a
|
||||
// world-readable file would make the 0600 the provider writes meaningless.
|
||||
await expect(ctx.plugin(CredentialsLocal, { path, watch: false }))
|
||||
.rejects.toThrow(/readable beyond its owner \(mode 644\)/)
|
||||
})
|
||||
|
||||
it('propagates a permission check that fails for a reason other than absence', async () => {
|
||||
const dir = await tempDir()
|
||||
const notADirectory = join(dir, 'occupied')
|
||||
await writeFile(notADirectory, 'a regular file\n')
|
||||
// An absent document is an empty store, but a path that cannot be
|
||||
// reached at all is a misconfiguration: the parent is a file, so the
|
||||
// check fails with ENOTDIR rather than concluding "no credentials yet".
|
||||
const ctx = new Context()
|
||||
await expect(ctx.plugin(CredentialsLocal, { path: join(notADirectory, '.credentials.yaml'), watch: false }))
|
||||
.rejects.toThrow(/ENOTDIR/)
|
||||
})
|
||||
|
||||
it('propagates a read that fails for a reason other than absence', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.credentials.yaml')
|
||||
// Owner-only, so the permission check passes, and unreadable as a file:
|
||||
// the store is present but cannot be parsed, which must fail the launch
|
||||
// rather than silently serve nothing.
|
||||
await mkdir(path, { mode: 0o700 })
|
||||
const ctx = new Context()
|
||||
await expect(ctx.plugin(CredentialsLocal, { path, watch: false })).rejects.toThrow(/EISDIR/)
|
||||
})
|
||||
|
||||
it('lets only the inherited environment shadow the store, read-only', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.credentials.yaml')
|
||||
await writeCredentials(path, 'DSH_CRED_TEST: stored\n')
|
||||
const ctx = await bootLayered(path, [
|
||||
{ source: 'process', values: { DSH_CRED_TEST: 'from-shell' } },
|
||||
{ source: 'user-env', path: '/home/.dsh/.env', values: { DSH_CRED_TEST: 'from-user-env' } },
|
||||
])
|
||||
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'from-shell', source: 'env' })
|
||||
expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'env', writable: false })
|
||||
await expect(ctx.credentials.set(KEY, 'next')).rejects.toThrow(/launching environment/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('document validation', () => {
|
||||
// Every rejection below is a boot failure rather than a skipped entry: this
|
||||
// document holds nothing but credentials, so an ignored key would read as
|
||||
// "the secret I stored has no effect".
|
||||
it.each([
|
||||
['a non-mapping root', 'just a string\n', /must be a mapping/],
|
||||
['a sequence root', '- DSH_CRED_TEST\n', /must be a mapping/],
|
||||
['a key that is not a POSIX identifier', 'not-a-ref: value\n', /credential ref/],
|
||||
['a non-string value', 'DSH_CRED_TEST: 123\n', /must be a string/],
|
||||
['an empty value', 'DSH_CRED_TEST: ""\n', /is empty/],
|
||||
['duplicate keys', 'DSH_CRED_TEST: one\nDSH_CRED_TEST: two\n', /invalid document/],
|
||||
['malformed yaml', 'DSH_CRED_TEST: "unterminated\n', /invalid document/],
|
||||
])('fails boot on %s', async (_case, text, message) => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.credentials.yaml')
|
||||
await writeCredentials(path, text)
|
||||
const ctx = new Context()
|
||||
await expect(ctx.plugin(CredentialsLocal, { path, watch: false })).rejects.toThrow(message)
|
||||
})
|
||||
|
||||
it('never puts a credential value in a diagnostic', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.credentials.yaml')
|
||||
const secret = 'sk-live-DO-NOT-LOG-abcdef123456'
|
||||
// The yaml parser's own message quotes the offending source line, which in
|
||||
// this document is the secret itself. Boot stderr and the watcher's logger
|
||||
// both receive whatever this throws.
|
||||
await writeCredentials(path, `DSH_CRED_TEST: "${secret}\n`)
|
||||
let failure: unknown
|
||||
try {
|
||||
await new Context().plugin(CredentialsLocal, { path, watch: false })
|
||||
} catch (error) {
|
||||
failure = error
|
||||
}
|
||||
expect(String(failure)).toMatch(/invalid document/)
|
||||
// The position survives; the line's contents do not.
|
||||
expect(String(failure)).toMatch(/line 2, column 1/)
|
||||
expect(String(failure)).not.toContain(secret)
|
||||
expect((failure as Error).stack ?? '').not.toContain(secret)
|
||||
})
|
||||
|
||||
it('reads an empty document as an empty store', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.credentials.yaml')
|
||||
await writeCredentials(path, '# nothing stored yet\n')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
expect(await ctx.credentials.resolve(KEY)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('document writes', () => {
|
||||
it('adds a missing key to a fresh 0600 document and emits the commit', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.credentials.yaml')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
const seen = updates(ctx)
|
||||
await ctx.credentials.set(KEY, 'sk-fresh')
|
||||
expect(await readFile(path, 'utf8')).toBe('DSH_CRED_TEST=sk-fresh\n')
|
||||
expect(await readFile(path, 'utf8')).toBe('DSH_CRED_TEST: sk-fresh\n')
|
||||
expect((await stat(path)).mode & 0o777).toBe(0o600)
|
||||
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'sk-fresh', source: 'file' })
|
||||
expect(seen).toEqual([KEY])
|
||||
})
|
||||
|
||||
it('rewrites one line in place, preserving every other byte and dropping duplicates', async () => {
|
||||
it('patches one entry, preserving comments and every untouched entry', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.env')
|
||||
await writeFile(path, '# deployment notes\nFIRST=one\n\nDSH_CRED_TEST=old\nTRAILING=x\nDSH_CRED_TEST=older')
|
||||
const path = join(dir, '.credentials.yaml')
|
||||
await writeCredentials(path, '# deployment notes\nDSH_CRED_OTHER: keep\n\n# the one under edit\nDSH_CRED_TEST: old\n')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
await ctx.credentials.set(KEY, 'new value!')
|
||||
expect(await readFile(path, 'utf8')).toBe('# deployment notes\nFIRST=one\n\nDSH_CRED_TEST=\'new value!\'\nTRAILING=x\n')
|
||||
expect(await readFile(path, 'utf8')).toBe(
|
||||
'# deployment notes\nDSH_CRED_OTHER: keep\n\n# the one under edit\nDSH_CRED_TEST: new value!\n',
|
||||
)
|
||||
})
|
||||
|
||||
it('quotes hostile values so they round-trip through a fresh provider', async () => {
|
||||
it('round-trips values no dotenv line could represent', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.env')
|
||||
const path = join(dir, '.credentials.yaml')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
const singleQuoted = 'with "quote", back\\slash and space'
|
||||
const doubleQuoted = "it's got an apostrophe"
|
||||
await ctx.credentials.set(KEY, singleQuoted)
|
||||
await ctx.credentials.set(OTHER, doubleQuoted)
|
||||
const multiLine = 'line one\nline two'
|
||||
const mixedQuotes = 'both \' and "'
|
||||
await ctx.credentials.set(KEY, multiLine)
|
||||
await ctx.credentials.set(OTHER, mixedQuotes)
|
||||
const reread = await boot({ path, watch: false })
|
||||
expect(await reread.credentials.resolve(KEY)).toEqual({ value: singleQuoted, source: 'file' })
|
||||
expect(await reread.credentials.resolve(OTHER)).toEqual({ value: doubleQuoted, source: 'file' })
|
||||
expect(await reread.credentials.resolve(KEY)).toEqual({ value: multiLine, source: 'file' })
|
||||
expect(await reread.credentials.resolve(OTHER)).toEqual({ value: mixedQuotes, source: 'file' })
|
||||
expect(await reread.credentials.describe(KEY)).toEqual({ configured: true, source: 'file', writable: true })
|
||||
})
|
||||
|
||||
it('fails loud on values no .env quoting style reads back verbatim', async () => {
|
||||
it('unsets only the owning entry, with its own annotation, and keeps an absent unset silent', async () => {
|
||||
const dir = await tempDir()
|
||||
const ctx = await boot({ path: join(dir, '.env'), watch: false })
|
||||
await expect(ctx.credentials.set(KEY, 'line one\nline two')).rejects.toThrow(/control characters/)
|
||||
await expect(ctx.credentials.set(KEY, 'both \' and "')).rejects.toThrow(/mixes quoting/)
|
||||
})
|
||||
|
||||
it('unsets only the owning line and keeps an absent unset silent', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.env')
|
||||
await writeFile(path, '# keep\nDSH_CRED_TEST=gone\nDSH_CRED_OTHER=stays\n')
|
||||
const path = join(dir, '.credentials.yaml')
|
||||
// Comments above an entry are that entry's annotation and go with it when
|
||||
// it is removed — including anything above the document's first entry.
|
||||
// Every other entry keeps its own comments.
|
||||
await writeCredentials(path, '# about the doomed one\nDSH_CRED_TEST: gone\n# about the survivor\nDSH_CRED_OTHER: stays\n')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
const seen = updates(ctx)
|
||||
await ctx.credentials.unset(KEY)
|
||||
expect(await readFile(path, 'utf8')).toBe('# keep\nDSH_CRED_OTHER=stays\n')
|
||||
expect(await readFile(path, 'utf8')).toBe('# about the survivor\nDSH_CRED_OTHER: stays\n')
|
||||
await ctx.credentials.unset(KEY)
|
||||
expect(seen).toEqual([KEY])
|
||||
})
|
||||
|
||||
it('rejects empty values, shadowed writes, and multi-line entries', async () => {
|
||||
it('rejects empty values and writes the environment would shadow', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.env')
|
||||
await writeFile(path, 'DSH_CRED_TEST="line one\nline two"\n')
|
||||
const path = join(dir, '.credentials.yaml')
|
||||
await writeCredentials(path, 'DSH_CRED_TEST: stored\n')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
|
||||
await expect(ctx.credentials.set(KEY, '')).rejects.toThrow(/empty value/)
|
||||
await expect(ctx.credentials.set(KEY, 'next')).rejects.toThrow(/multi-line/)
|
||||
await expect(ctx.credentials.unset(KEY)).rejects.toThrow(/multi-line/)
|
||||
|
||||
vi.stubEnv('DSH_CRED_TEST', 'shadowing')
|
||||
await expect(ctx.credentials.set(KEY, 'next')).rejects.toThrow(/shadowed/)
|
||||
await expect(ctx.credentials.unset(KEY)).rejects.toThrow(/shadowed/)
|
||||
})
|
||||
|
||||
it('leaves an empty document after unsetting the only entry', async () => {
|
||||
it('leaves an empty mapping after unsetting the only entry', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.env')
|
||||
await writeFile(path, 'DSH_CRED_TEST=only\n')
|
||||
const path = join(dir, '.credentials.yaml')
|
||||
await writeCredentials(path, 'DSH_CRED_TEST: only\n')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
await ctx.credentials.unset(KEY)
|
||||
expect(await readFile(path, 'utf8')).toBe('')
|
||||
expect(await readFile(path, 'utf8')).toBe('{}\n')
|
||||
// The emptied document still reloads as an empty store, not a parse error.
|
||||
const reread = await boot({ path, watch: false })
|
||||
expect(await reread.credentials.resolve(KEY)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('fails a write loud when the on-disk document became invalid', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.credentials.yaml')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
// An external editor left the document unparsable: the read-modify-write
|
||||
// must refuse rather than overwrite content it cannot understand.
|
||||
await writeCredentials(path, 'DSH_CRED_TEST: "unterminated\n')
|
||||
await expect(ctx.credentials.set(OTHER, 'lands')).rejects.toThrow(/invalid document/)
|
||||
})
|
||||
|
||||
it('chains past a rejected write so one bad value cannot poison the queue', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.env')
|
||||
const path = join(dir, '.credentials.yaml')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
const bad = expect(ctx.credentials.set(KEY, 'both \' and "')).rejects.toThrow(/mixes quoting/)
|
||||
const bad = expect(ctx.credentials.set(KEY, '')).rejects.toThrow(/empty value/)
|
||||
const good = ctx.credentials.set(OTHER, 'lands')
|
||||
await bad
|
||||
await good
|
||||
expect(await readFile(path, 'utf8')).toBe('DSH_CRED_OTHER=lands\n')
|
||||
expect(await readFile(path, 'utf8')).toBe('DSH_CRED_OTHER: lands\n')
|
||||
})
|
||||
|
||||
it('serializes concurrent writes so both land in the one document', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.env')
|
||||
const path = join(dir, '.credentials.yaml')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
await Promise.all([
|
||||
ctx.credentials.set(KEY, 'one'),
|
||||
ctx.credentials.set(OTHER, 'two'),
|
||||
])
|
||||
expect(await readFile(path, 'utf8')).toBe('DSH_CRED_TEST=one\nDSH_CRED_OTHER=two\n')
|
||||
expect(await readFile(path, 'utf8')).toBe('DSH_CRED_TEST: one\nDSH_CRED_OTHER: two\n')
|
||||
})
|
||||
|
||||
it('refuses writes after disposal', async () => {
|
||||
const dir = await tempDir()
|
||||
const ctx = new Context()
|
||||
const fiber = ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false })
|
||||
const fiber = ctx.plugin(CredentialsLocal, { path: join(dir, '.credentials.yaml'), watch: false })
|
||||
await fiber
|
||||
// Capture the handle first: disposal also removes the ctx.credentials service.
|
||||
const service = ctx.credentials
|
||||
@@ -215,20 +391,20 @@ describe('line-editing writes', () => {
|
||||
describe('real hot reload', () => {
|
||||
it('publishes external edits, replaces the snapshot wholesale, and suppresses self-writes', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.env')
|
||||
const path = join(dir, '.credentials.yaml')
|
||||
// Watching starts on an existing document: creation racing watcher setup
|
||||
// is a chokidar readiness gap, not the reload contract under test.
|
||||
await writeFile(path, 'DSH_CRED_TEST=boot\n')
|
||||
await writeCredentials(path, 'DSH_CRED_TEST: boot\n')
|
||||
const ctx = await boot({ path, debounceMs: 10 })
|
||||
const seen = updates(ctx)
|
||||
|
||||
await writeFile(path, 'DSH_CRED_TEST=live\nDSH_CRED_OTHER=extra\n')
|
||||
await writeCredentials(path, 'DSH_CRED_TEST: live\nDSH_CRED_OTHER: extra\n')
|
||||
await vi.waitFor(async () => {
|
||||
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'live', source: 'file' })
|
||||
})
|
||||
|
||||
// Wholesale replacement: an entry deleted on disk never lingers in memory.
|
||||
await writeFile(path, 'DSH_CRED_TEST=live\n')
|
||||
await writeCredentials(path, 'DSH_CRED_TEST: live\n')
|
||||
await vi.waitFor(async () => {
|
||||
expect(await ctx.credentials.resolve(OTHER)).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Third-review behaviors: read-modify-write under the writer lock (external
|
||||
// edits survive an API write), the contained credentials/updated fan-out (a
|
||||
// broken observer never fails a committed write), and the physical-line
|
||||
// editor's multi-line and CRLF discipline.
|
||||
// broken observer never fails a committed write), and the YAML document
|
||||
// editor's isolation between entries.
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
||||
@@ -10,6 +10,11 @@ import { join } from 'node:path'
|
||||
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
||||
import { CredentialsLocal } from '../src/index.ts'
|
||||
|
||||
/** Credential documents are seeded owner-only, exactly as the provider creates them. */
|
||||
function writeCredentials(file: string, text: string): Promise<void> {
|
||||
return writeFile(file, text, { mode: 0o600 })
|
||||
}
|
||||
|
||||
const ALPHA = credentialRef('DSH_REVIEW_ALPHA')
|
||||
const BETA = credentialRef('DSH_REVIEW_BETA')
|
||||
const INNER = credentialRef('DSH_REVIEW_INNER')
|
||||
@@ -37,18 +42,18 @@ async function boot(config: ConstructorParameters<typeof CredentialsLocal>[1]):
|
||||
describe('read-modify-write', () => {
|
||||
it('folds an unobserved external edit into a write instead of overwriting it', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.env')
|
||||
const path = join(dir, '.credentials.yaml')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
const seen: string[] = []
|
||||
ctx.on('credentials/updated', (ref) => { seen.push(ref) })
|
||||
await ctx.credentials.set(ALPHA, 'one')
|
||||
// The external edit has landed on disk but no watcher reported it (watch
|
||||
// is off — the same blind spot as a debounce window or a missed event).
|
||||
await writeFile(path, `${ALPHA}=one\n${BETA}=external\n`)
|
||||
await writeCredentials(path, `${ALPHA}: one\n${BETA}: external\n`)
|
||||
await ctx.credentials.set(ALPHA, 'two')
|
||||
const text = await readFile(path, 'utf8')
|
||||
expect(text).toContain(`${BETA}=external`)
|
||||
expect(text).toContain(`${ALPHA}=two`)
|
||||
expect(text).toContain(`${BETA}: external`)
|
||||
expect(text).toContain(`${ALPHA}: two`)
|
||||
// The fold published the unobserved entry before the write's own commit.
|
||||
expect(seen).toEqual([ALPHA, BETA, ALPHA])
|
||||
expect(await ctx.credentials.resolve(BETA)).toEqual({ value: 'external', source: 'file' })
|
||||
@@ -56,7 +61,7 @@ describe('read-modify-write', () => {
|
||||
|
||||
it('keeps both refs when two providers write the same document concurrently', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.env')
|
||||
const path = join(dir, '.credentials.yaml')
|
||||
const first = await boot({ path, watch: false })
|
||||
const second = await boot({ path, watch: false })
|
||||
await Promise.all([
|
||||
@@ -71,7 +76,7 @@ describe('read-modify-write', () => {
|
||||
it('creates the credentials directory owner-only', async () => {
|
||||
const dir = await tempDir()
|
||||
const home = join(dir, 'home')
|
||||
const ctx = await boot({ path: join(home, '.env'), watch: false })
|
||||
const ctx = await boot({ path: join(home, '.credentials.yaml'), watch: false })
|
||||
await ctx.credentials.set(ALPHA, 'one')
|
||||
expect((await stat(home)).mode & 0o777).toBe(0o700)
|
||||
})
|
||||
@@ -80,7 +85,7 @@ describe('read-modify-write', () => {
|
||||
describe('contained update fan-out', () => {
|
||||
it('does not fail a committed set when a listener throws, and later listeners still run', async () => {
|
||||
const dir = await tempDir()
|
||||
const ctx = await boot({ path: join(dir, '.env'), watch: false })
|
||||
const ctx = await boot({ path: join(dir, '.credentials.yaml'), watch: false })
|
||||
ctx.on('credentials/updated', () => {
|
||||
throw new Error('observer boom')
|
||||
})
|
||||
@@ -93,7 +98,7 @@ describe('contained update fan-out', () => {
|
||||
|
||||
it('contains an async listener rejection', async () => {
|
||||
const dir = await tempDir()
|
||||
const ctx = await boot({ path: join(dir, '.env'), watch: false })
|
||||
const ctx = await boot({ path: join(dir, '.credentials.yaml'), watch: false })
|
||||
// An unknown-returning function keeps the typed surface legal while the
|
||||
// runtime value is still the rejected promise the containment must handle.
|
||||
const boom = (): unknown => Promise.reject(new Error('async observer boom'))
|
||||
@@ -104,7 +109,7 @@ describe('contained update fan-out', () => {
|
||||
|
||||
it('rethrows an invariant-coded failure after the commit and the remaining listeners', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.env')
|
||||
const path = join(dir, '.credentials.yaml')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
ctx.on('credentials/updated', () => {
|
||||
throw Object.assign(new Error('forged relation'), { code: 'INVARIANT' })
|
||||
@@ -114,78 +119,33 @@ describe('contained update fan-out', () => {
|
||||
await expect(ctx.credentials.set(ALPHA, 'one')).rejects.toThrow(/forged relation/)
|
||||
// Harness-fatal by design — but the write itself committed first.
|
||||
expect(second).toHaveBeenCalledWith(ALPHA)
|
||||
expect(await readFile(path, 'utf8')).toContain(`${ALPHA}=one`)
|
||||
expect(await readFile(path, 'utf8')).toContain(`${ALPHA}: one`)
|
||||
expect(await ctx.credentials.resolve(ALPHA)).toEqual({ value: 'one', source: 'file' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('physical-line editor', () => {
|
||||
it('never mistakes a quoted multi-line continuation for an assignment', async () => {
|
||||
describe('document editor', () => {
|
||||
it('leaves a sibling multi-line value untouched while patching one entry', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.env')
|
||||
const wrapped = `DSH_REVIEW_WRAPPED="line1\n${INNER}=looks-like-one\nline3"\n${ALPHA}=a\n`
|
||||
await writeFile(path, wrapped)
|
||||
const path = join(dir, '.credentials.yaml')
|
||||
const wrapped = `DSH_REVIEW_WRAPPED: |-\n line1\n line2\n${ALPHA}: a\n`
|
||||
await writeCredentials(path, wrapped)
|
||||
const ctx = await boot({ path, watch: false })
|
||||
await ctx.credentials.set(ALPHA, 'b')
|
||||
// The wrapped value survives byte-for-byte; only ALPHA's line changed.
|
||||
const afterAlpha = await readFile(path, 'utf8')
|
||||
expect(afterAlpha).toBe(`DSH_REVIEW_WRAPPED="line1\n${INNER}=looks-like-one\nline3"\n${ALPHA}=b\n`)
|
||||
// Setting the inner-looking ref appends a real assignment; the
|
||||
// continuation line inside the quoted value stays untouched.
|
||||
await ctx.credentials.set(INNER, 'real')
|
||||
const afterInner = await readFile(path, 'utf8')
|
||||
expect(afterInner).toBe(`DSH_REVIEW_WRAPPED="line1\n${INNER}=looks-like-one\nline3"\n${ALPHA}=b\n${INNER}=real\n`)
|
||||
expect(await ctx.credentials.resolve(INNER)).toEqual({ value: 'real', source: 'file' })
|
||||
expect(await readFile(path, 'utf8')).toBe(`DSH_REVIEW_WRAPPED: |-\n line1\n line2\n${ALPHA}: b\n`)
|
||||
expect(await ctx.credentials.resolve(credentialRef('DSH_REVIEW_WRAPPED')))
|
||||
.toEqual({ value: 'line1\nline2', source: 'file' })
|
||||
})
|
||||
|
||||
it('preserves CRLF line endings on untouched and edited lines', async () => {
|
||||
it('stores a value that looks like another entry without creating one', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.env')
|
||||
await writeFile(path, `# note\r\n${ALPHA}=a\r\n${BETA}=keep\r\n`)
|
||||
const path = join(dir, '.credentials.yaml')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
await ctx.credentials.set(ALPHA, 'b')
|
||||
expect(await readFile(path, 'utf8')).toBe(`# note\r\n${ALPHA}=b\r\n${BETA}=keep\r\n`)
|
||||
await ctx.credentials.set(INNER, 'new')
|
||||
expect(await readFile(path, 'utf8')).toBe(`# note\r\n${ALPHA}=b\r\n${BETA}=keep\r\n${INNER}=new\r\n`)
|
||||
})
|
||||
|
||||
it('terminates a final unterminated line before appending', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.env')
|
||||
await writeFile(path, `${ALPHA}=a`)
|
||||
const ctx = await boot({ path, watch: false })
|
||||
await ctx.credentials.set(BETA, 'b')
|
||||
expect(await readFile(path, 'utf8')).toBe(`${ALPHA}=a\n${BETA}=b\n`)
|
||||
})
|
||||
|
||||
it('rewrites a final unterminated assignment in the dominant ending style', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.env')
|
||||
await writeFile(path, `${ALPHA}=a`)
|
||||
const ctx = await boot({ path, watch: false })
|
||||
await ctx.credentials.set(ALPHA, 'b')
|
||||
expect(await readFile(path, 'utf8')).toBe(`${ALPHA}=b\n`)
|
||||
})
|
||||
|
||||
it('tracks a single-quoted multi-line value through its continuation', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.env')
|
||||
await writeFile(path, `DSH_REVIEW_SQ='line1\n${INNER}=shadow\nline3'\n`)
|
||||
const ctx = await boot({ path, watch: false })
|
||||
await ctx.credentials.set(ALPHA, 'x')
|
||||
expect(await readFile(path, 'utf8'))
|
||||
.toBe(`DSH_REVIEW_SQ='line1\n${INNER}=shadow\nline3'\n${ALPHA}=x\n`)
|
||||
})
|
||||
|
||||
it('reports a multi-line entry as unwritable and refuses to edit it', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.env')
|
||||
await writeFile(path, `${ALPHA}="line1\nline2"\n`)
|
||||
const ctx = await boot({ path, watch: false })
|
||||
expect(await ctx.credentials.describe(ALPHA)).toEqual({ configured: true, source: 'file', writable: false })
|
||||
await expect(ctx.credentials.set(ALPHA, 'flat')).rejects.toThrow(/multi-line entry/)
|
||||
await expect(ctx.credentials.unset(ALPHA)).rejects.toThrow(/multi-line entry/)
|
||||
// Resolution still serves the multi-line value.
|
||||
expect(await ctx.credentials.resolve(ALPHA)).toEqual({ value: 'line1\nline2', source: 'file' })
|
||||
// The stored text must stay a value: a quoted-scalar write that leaked its
|
||||
// own structure would silently mint a credential nobody stored.
|
||||
await ctx.credentials.set(ALPHA, `${INNER}: injected`)
|
||||
const reread = await boot({ path, watch: false })
|
||||
expect(await reread.credentials.resolve(ALPHA)).toEqual({ value: `${INNER}: injected`, source: 'file' })
|
||||
expect(await reread.credentials.resolve(INNER)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,11 @@ import { join } from 'node:path'
|
||||
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
||||
import { CredentialsLocal } from '../src/index.ts'
|
||||
|
||||
/** Credential documents are seeded owner-only, exactly as the provider creates them. */
|
||||
function writeCredentials(file: string, text: string): Promise<void> {
|
||||
return writeFile(file, text, { mode: 0o600 })
|
||||
}
|
||||
|
||||
// chokidar is the nondeterministic OS boundary: faking it lets these tests
|
||||
// drive the event pipeline (error events, races with unreadable files)
|
||||
// deterministically. Real end-to-end watching stays covered by local.spec.ts.
|
||||
@@ -66,21 +71,21 @@ async function boot(config: ConstructorParameters<typeof CredentialsLocal>[1]):
|
||||
describe('watcher pipeline', () => {
|
||||
it('clamps the write-settle poll interval for a zero debounce', async () => {
|
||||
const dir = await tempDir()
|
||||
await boot({ path: join(dir, '.env'), debounceMs: 0 })
|
||||
await boot({ path: join(dir, '.credentials.yaml'), debounceMs: 0 })
|
||||
const [instance] = await fakeInstances()
|
||||
expect(instance!.options.awaitWriteFinish).toEqual({ stabilityThreshold: 0, pollInterval: 1 })
|
||||
})
|
||||
|
||||
it('survives a watcher error and keeps publishing later edits', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.env')
|
||||
const path = join(dir, '.credentials.yaml')
|
||||
const ctx = await boot({ path, debounceMs: 5 })
|
||||
const [instance] = await fakeInstances()
|
||||
|
||||
instance!.watcher.emit('error', new Error('watch backend failure'))
|
||||
expect(await ctx.credentials.resolve(KEY)).toBeUndefined()
|
||||
|
||||
await writeFile(path, 'DSH_CRED_PIPE=arrived\n')
|
||||
await writeCredentials(path, 'DSH_CRED_PIPE: arrived\n')
|
||||
instance!.watcher.emit('all', 'change', path)
|
||||
await vi.waitFor(async () => {
|
||||
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'arrived', source: 'file' })
|
||||
@@ -89,8 +94,8 @@ describe('watcher pipeline', () => {
|
||||
|
||||
it('keeps the last good snapshot when the file turns unreadable at runtime', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.env')
|
||||
await writeFile(path, 'DSH_CRED_PIPE=good\n')
|
||||
const path = join(dir, '.credentials.yaml')
|
||||
await writeCredentials(path, 'DSH_CRED_PIPE: good\n')
|
||||
const ctx = await boot({ path, debounceMs: 5 })
|
||||
|
||||
await chmod(path, 0o000)
|
||||
@@ -104,7 +109,7 @@ describe('watcher pipeline', () => {
|
||||
|
||||
it('keeps the reload queue alive after an invariant violation escapes the fan-out', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.env')
|
||||
const path = join(dir, '.credentials.yaml')
|
||||
const ctx = await boot({ path, debounceMs: 5 })
|
||||
let arm = true
|
||||
ctx.on('credentials/updated', () => {
|
||||
@@ -113,7 +118,7 @@ describe('watcher pipeline', () => {
|
||||
})
|
||||
const [instance] = await fakeInstances()
|
||||
|
||||
await writeFile(path, 'DSH_CRED_PIPE=first\n')
|
||||
await writeCredentials(path, 'DSH_CRED_PIPE: first\n')
|
||||
instance!.watcher.emit('all', 'change', path)
|
||||
// The snapshot commits before the fan-out, so the value lands even though
|
||||
// the listener threw out of the refresh.
|
||||
@@ -122,7 +127,7 @@ describe('watcher pipeline', () => {
|
||||
})
|
||||
|
||||
arm = false
|
||||
await writeFile(path, 'DSH_CRED_PIPE=second\n')
|
||||
await writeCredentials(path, 'DSH_CRED_PIPE: second\n')
|
||||
instance!.watcher.emit('all', 'change', path)
|
||||
await vi.waitFor(async () => {
|
||||
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'second', source: 'file' })
|
||||
@@ -131,8 +136,8 @@ describe('watcher pipeline', () => {
|
||||
|
||||
it('quiesces the refresh pipeline before dispose completes', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.env')
|
||||
await writeFile(path, 'DSH_CRED_PIPE=initial\n')
|
||||
const path = join(dir, '.credentials.yaml')
|
||||
await writeCredentials(path, 'DSH_CRED_PIPE: initial\n')
|
||||
const ctx = new Context()
|
||||
const fiber = ctx.plugin(CredentialsLocal, { path, debounceMs: 5 })
|
||||
await fiber
|
||||
@@ -142,7 +147,7 @@ describe('watcher pipeline', () => {
|
||||
if (disposed) postDisposeCommits += 1
|
||||
})
|
||||
|
||||
await writeFile(path, 'DSH_CRED_PIPE=changed\n')
|
||||
await writeCredentials(path, 'DSH_CRED_PIPE: changed\n')
|
||||
const [instance] = await fakeInstances()
|
||||
// Two queued refreshes: dispose interrupts one mid-flight and the other
|
||||
// before it starts, so both closed guards must hold.
|
||||
@@ -158,8 +163,8 @@ describe('watcher pipeline', () => {
|
||||
|
||||
it('empties the snapshot when the document is deleted and emits the removals', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.env')
|
||||
await writeFile(path, 'DSH_CRED_PIPE=doomed\n')
|
||||
const path = join(dir, '.credentials.yaml')
|
||||
await writeCredentials(path, 'DSH_CRED_PIPE: doomed\n')
|
||||
const ctx = await boot({ path, debounceMs: 5 })
|
||||
const seen: string[] = []
|
||||
ctx.on('credentials/updated', (ref) => {
|
||||
@@ -175,30 +180,39 @@ describe('watcher pipeline', () => {
|
||||
expect(seen).toEqual([KEY])
|
||||
})
|
||||
|
||||
it('publishes only seam-addressable keys and preserves the rest untouched', async () => {
|
||||
it('keeps the last good snapshot when an external edit makes the document invalid', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.env')
|
||||
await writeFile(path, 'BAD-KEY=1\nDSH_CRED_PIPE=a\n')
|
||||
const path = join(dir, '.credentials.yaml')
|
||||
await writeCredentials(path, 'DSH_CRED_PIPE: a\n')
|
||||
const ctx = await boot({ path, debounceMs: 5 })
|
||||
const seen: string[] = []
|
||||
ctx.on('credentials/updated', (ref) => {
|
||||
seen.push(ref)
|
||||
})
|
||||
|
||||
await writeFile(path, 'BAD-KEY=2\nDSH_CRED_PIPE=b\n')
|
||||
// A key the seam cannot address is a rejection, not preserved content:
|
||||
// this document holds nothing but credentials. A live reload must warn
|
||||
// and keep serving the last good snapshot rather than take the process
|
||||
// down or silently drop the entry it could not validate.
|
||||
await writeCredentials(path, 'BAD-KEY: 2\nDSH_CRED_PIPE: b\n')
|
||||
const [instance] = await fakeInstances()
|
||||
instance!.watcher.emit('all', 'change', path)
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'a', source: 'file' })
|
||||
expect(seen).toEqual([])
|
||||
|
||||
// Repairing the document resumes publishing.
|
||||
await writeCredentials(path, 'DSH_CRED_PIPE: b\n')
|
||||
instance!.watcher.emit('all', 'change', path)
|
||||
await vi.waitFor(async () => {
|
||||
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'b', source: 'file' })
|
||||
})
|
||||
// The dash-named key is preserved file content the seam cannot address:
|
||||
// its change publishes nothing and breaks nothing.
|
||||
expect(seen).toEqual([KEY])
|
||||
})
|
||||
|
||||
it('treats an event for a still-absent file as a no-op', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.env')
|
||||
const path = join(dir, '.credentials.yaml')
|
||||
const ctx = await boot({ path, debounceMs: 5 })
|
||||
const [instance] = await fakeInstances()
|
||||
instance!.watcher.emit('all', 'add', path)
|
||||
@@ -208,12 +222,12 @@ describe('watcher pipeline', () => {
|
||||
|
||||
it('reconciles at watcher ready so a change during setup is not missed', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.env')
|
||||
await writeFile(path, `${KEY}=a\n`)
|
||||
const path = join(dir, '.credentials.yaml')
|
||||
await writeCredentials(path, `${KEY}: a\n`)
|
||||
const ctx = await boot({ path, debounceMs: 5 })
|
||||
// Written after the initial load but before the watcher became active:
|
||||
// no 'all' event will ever fire for it.
|
||||
await writeFile(path, `${KEY}=written-before-ready\n`)
|
||||
await writeCredentials(path, `${KEY}: written-before-ready\n`)
|
||||
const [instance] = await fakeInstances()
|
||||
instance!.watcher.emit('ready')
|
||||
await vi.waitFor(async () => {
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
{
|
||||
"path": "../../util/atomic-write"
|
||||
},
|
||||
{
|
||||
"path": "../../util/environment"
|
||||
},
|
||||
{
|
||||
"path": "../../util/paths"
|
||||
},
|
||||
|
||||
@@ -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 packages/credentials/credentials/README.md
|
||||
README.md: 1c18c4762360ad081227b7097cd82ddab4fcdefc
|
||||
README.zh.md: 751fb7c1e8326cef91b925c5f8b9f40d92e1bba6
|
||||
README.md: 95ef76d145727340d8135bf1d48babd6d8adb882
|
||||
README.zh.md: b3404858025d4ec53a76548c78c1808d2c858844
|
||||
@@ -31,7 +31,7 @@ The shadowing rule on `set`/`unset` is deliberate fail-loud: when a read-only so
|
||||
|
||||
## Providers
|
||||
|
||||
[`dsh-credentials-local`](../credentials-local/README.md) layers the live process environment over a `$DSH_HOME/.env` file. The seam shape leaves room for keyring-, helper-command-, and KMS-backed providers; a remote settings provider never needs to carry secrets.
|
||||
[`dsh-credentials-local`](../credentials-local/README.md) layers the inherited process environment over its managed `$DSH_HOME/.credentials.yaml` document, with the launcher's project and user `.env` layers as fallbacks. The seam shape leaves room for keyring-, helper-command-, and KMS-backed providers; a remote settings provider never needs to carry secrets.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ await ctx.credentials.unset(ref) // no-op when absent; s
|
||||
|
||||
## Providers
|
||||
|
||||
[`dsh-credentials-local`](../credentials-local/README.md) 把活跃进程环境叠加在 `$DSH_HOME/.env` 文件之上。seam 形状为 keyring、辅助命令、KMS 后端的 provider 留好了位置;远端 settings provider 永远不必携带机密。
|
||||
[`dsh-credentials-local`](../credentials-local/README.md) 把继承的进程环境叠加在其受管 `$DSH_HOME/.credentials.yaml` 文档之上,并以启动器的项目和用户 `.env` 层作为后备。seam 形状为 keyring、辅助命令、KMS 后端的 provider 留好了位置;远端 settings provider 永远不必携带机密。
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ export function credentialRef(value: string): CredentialRef {
|
||||
export interface ResolvedCredential {
|
||||
/** The non-empty secret value. */
|
||||
value: string
|
||||
/** Provider-defined source layer id (the local provider uses `env` and `file`). */
|
||||
/** Provider-defined source layer id (the local provider uses `env`, `file`, `project-env`, and `user-env`). */
|
||||
source: string
|
||||
}
|
||||
|
||||
|
||||
@@ -33,8 +33,6 @@ const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.m
|
||||
const CORDIS_YML = `
|
||||
- id: llm-deepseek
|
||||
name: '@deepseek-ai/dsh-llm-deepseek'
|
||||
config:
|
||||
apiKey: !!js process.env.DEEPSEEK_API_KEY
|
||||
- id: subprocess
|
||||
name: '@deepseek-ai/dsh-subprocess-local'
|
||||
- id: bash
|
||||
|
||||
@@ -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 packages/llm/llm-deepseek/README.md
|
||||
README.md: c6435d0bdfbb9758b6f86ef38e94159a9ccdc36d
|
||||
README.zh.md: f37286ade023ceecf6bfc87eb08fd4d80a5a3912
|
||||
README.md: 6ad674ebdf8da4fd927a9499e80e06462d3c0dfb
|
||||
README.zh.md: 02a192e38aa19b24a34e83b3ddb77d786bbace0b
|
||||
@@ -15,7 +15,6 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire
|
||||
name: '@deepseek-ai/dsh-llm-deepseek'
|
||||
config:
|
||||
apiKeyEnv: DEEPSEEK_API_KEY # default; resolved per request via ctx.credentials, then the environment
|
||||
# apiKey: … # literal escape hatch; prefer the reference so no secret enters this file
|
||||
baseURL: https://api.deepseek.com # optional; $DEEPSEEK_BASE_URL then the public API when omitted
|
||||
thinking: enabled # optional; provider default is enabled
|
||||
reasoningEffort: high # optional; off | high | max — omitted ⇒ high
|
||||
@@ -53,7 +52,7 @@ The same exact-model result exposes ordered `off`, `high`, and `max` efforts und
|
||||
Connection facts are not frozen at load. `resolveAdapterOptions` is the one explicit resolve step from raw config to validated facts, and the adapter re-reads them through a thunk **once per operation**: base URL, catalog, request defaults, and idle budget all take effect on the next request, while an in-flight stream keeps the facts it started with. Two optional seams feed that thunk:
|
||||
|
||||
- **`ctx.settings`** — the plugin registers the `llm-deepseek` namespace with this same `Config` schema and its `cordis.yml` entry as the composition `base`, so a `llm-deepseek:` section in the user settings document overrides any field without a restart. Without a mounted settings service the entry config alone drives the adapter, unchanged. A live settings snapshot that passes the schema but fails a beyond-schema bound (a duplicate catalog id, a broken thinking/effort pair) keeps the last good facts and logs the failure; the entry config itself still fails plugin load.
|
||||
- **`ctx.credentials`** — the API key resolves per stream call, from the *same* resolved snapshot that supplies the endpoint: a trimmed, non-empty literal `apiKey` wins, then `apiKeyEnv` through the credential seam (`$DSH_HOME/.env` under the live environment), then — only without a mounted seam — the raw environment variable. Whitespace-only literals are absent rather than Authorization values. Because credential facts travel with the connection facts, a settings snapshot the resolver rejects contributes neither its endpoint nor its key: the whole previous generation keeps serving. Every key is format-checked before use — a literal at connection-facts resolution (plugin load, or the next settings snapshot), a stored or ambient value at request time — so a value no HTTP header can carry is refused there instead of surfacing as an opaque `fetch` `TypeError`; the request-time check throws `LlmError('INVALID_CREDENTIAL')` naming the failing entry point but never any part of the key. A request with no key anywhere fails with `MISSING_CREDENTIAL` naming every configuration entry point, while the route stays registered and the catalog stays browsable — first-run onboarding is "browse models, store the key, prompt again", with no restart between.
|
||||
- **`ctx.credentials`** — the API key resolves per stream call, from the *same* resolved snapshot that supplies the endpoint. Configuration carries only `apiKeyEnv`, never a literal key: the reference resolves through the credential seam, and without a mounted seam through the trusted environment layers. Because credential facts travel with the connection facts, a settings snapshot the resolver rejects contributes neither its endpoint nor its key: the whole previous generation keeps serving. Every resolved key is format-checked before use, so a value no HTTP header can carry is refused with `LlmError('INVALID_CREDENTIAL')` naming the failing entry point — never any part of the key — instead of surfacing as an opaque `fetch` `TypeError`. A request with no key anywhere fails with `MISSING_CREDENTIAL` naming every configuration entry point, while the route stays registered and the catalog stays browsable — first-run onboarding is "browse models, store the key, prompt again", with no restart between.
|
||||
|
||||
The one registration-captured fact is the retry policy: when its resolved value changes, the plugin re-registers the route in place (same adapter instance, one synchronous section), so `ctx.llm.providerRetryPolicy('deepseek-official')` always reports the current policy.
|
||||
|
||||
@@ -108,7 +107,6 @@ Loop-retained response blocks append to the next request and preserve its earlie
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **A settings `models` list replaces the composition list wholesale** — settings-layer merging is per-field, and arrays are one field; per-entry catalog merging would need a keyed shape.
|
||||
- **`Config.apiKey` is redacted on the wire but still a stored literal** — `describe({ redactSecrets: true })` strips it and reports the slot, so a configuration UI never receives the value; the key is nonetheless stored in the settings document rather than the credential store, so prefer `apiKeyEnv`.
|
||||
- **`tool_choice` is not mapped** — not part of the core vocabulary (MVP cut, shared with the pi-ai twin).
|
||||
- **Requests use raw `fetch`, not `@cordisjs/plugin-http`** — no shared proxy/interception configuration; adoption is deferred until a second adapter wants it (`TODO(http)`).
|
||||
- **Serialization flattens user and tool-result content to text blocks** — plugin-added block types are skipped, and empty tool output crosses the wire as the literal `(no output)`.
|
||||
@@ -15,7 +15,6 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器:
|
||||
name: '@deepseek-ai/dsh-llm-deepseek'
|
||||
config:
|
||||
apiKeyEnv: DEEPSEEK_API_KEY # default; resolved per request via ctx.credentials, then the environment
|
||||
# apiKey: … # literal escape hatch; prefer the reference so no secret enters this file
|
||||
baseURL: https://api.deepseek.com # optional; $DEEPSEEK_BASE_URL then the public API when omitted
|
||||
thinking: enabled # optional; provider default is enabled
|
||||
reasoningEffort: high # optional; off | high | max — omitted ⇒ high
|
||||
@@ -53,7 +52,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器:
|
||||
连接事实不在加载时冻结。`resolveAdapterOptions` 是从原始配置到已校验事实的唯一显式 resolve 步骤,适配器经由一个 thunk **每操作重读一次**:base URL、catalog、请求默认值与 idle 预算都在下一次请求生效,进行中的流则保持其起始事实。两个可选 seam 供给该 thunk:
|
||||
|
||||
- **`ctx.settings`**——插件用同一份 `Config` schema 注册 `llm-deepseek` namespace,并以其 `cordis.yml` 条目为组合 `base`,因此用户设置文档中的 `llm-deepseek:` 分节可以免重启覆盖任何字段。未挂载 settings 服务时,仅由 entry 配置驱动适配器,行为不变。存活 settings 快照若通过 schema 却违反 schema 之外的约束(重复的 catalog id、无法成立的 thinking/推理强度组合),则保留最后可用事实并记录失败;entry 配置本身仍会使插件加载失败。
|
||||
- **`ctx.credentials`**——API 密钥按每次 stream 调用解析,取自与端点*同一*份解析后的快照:去除首尾空白后非空的字面 `apiKey` 优先,其次经凭据 seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`),最后——仅在未挂载 seam 时——读取原始环境变量。纯空白字面值会被视为缺失,而不会成为 Authorization 值。由于凭据事实与连接事实同行,被 resolver 拒绝的 settings 快照既不贡献自己的端点,也不贡献自己的密钥:整个先前世代继续服务。每个密钥在使用前都会被校验格式——字面量在连接事实解析时(插件加载或下一次 settings 快照)校验,已存储的值或环境变量值则在请求时校验——因此 HTTP 标头无法承载的值会在这一步被拒绝,而不是以语义不明的 `fetch` `TypeError` 形式浮现;请求时校验会抛出 `LlmError('INVALID_CREDENTIAL')`,点名失败的入口,但绝不透露密钥的任何部分。任何地方都没有密钥的请求以 `MISSING_CREDENTIAL` 失败,并点名每个配置入口,同时路由保持注册、catalog 保持可浏览——首次运行的上手流程就是「浏览模型、存入密钥、再次发起提示」,中间无需任何重启。
|
||||
- **`ctx.credentials`**——API 密钥按每次 stream 调用解析,取自与端点*同一*份解析后的快照。配置只携带 `apiKeyEnv`,从不携带字面密钥:该引用经凭据 seam 解析,未挂载 seam 时则经受信环境层解析。由于凭据事实与连接事实同行,被 resolver 拒绝的 settings 快照既不贡献自己的端点,也不贡献自己的密钥:整个先前世代继续服务。每个解析出的密钥在使用前都会被校验格式,因此 HTTP 标头无法承载的值会以 `LlmError('INVALID_CREDENTIAL')` 被拒绝,点名失败的入口,但绝不透露密钥的任何部分,而不是以语义不明的 `fetch` `TypeError` 形式浮现。任何地方都没有密钥的请求以 `MISSING_CREDENTIAL` 失败,并点名每个配置入口,同时路由保持注册、catalog 保持可浏览——首次运行的上手流程就是「浏览模型、存入密钥、再次发起提示」,中间无需任何重启。
|
||||
|
||||
唯一在注册期捕获的事实是重试策略:其解析值变化时,插件原地重新注册该路由(同一适配器实例、一个同步区段),因此 `ctx.llm.providerRetryPolicy('deepseek-official')` 始终报告当前策略。
|
||||
|
||||
@@ -108,7 +107,6 @@ loop 保留的响应块会追加到下一个请求,并保留其较早可复用
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **settings 的 `models` 列表会整体替换组合列表**:settings 层按字段合并,而数组是单个字段;按条目合并 catalog 需要带键的形状。
|
||||
- **`Config.apiKey` 在协议上已脱敏,但仍是一个已存的字面值**:`describe({ redactSecrets: true })` 会把它剥离并报告该槽位,配置 UI 因此永远收不到该值;但这个密钥仍存放在 settings 文档而非凭据存储中,所以请优先使用 `apiKeyEnv`。
|
||||
- **未映射 `tool_choice`**:它不属于核心词汇(MVP 取舍,与 pi-ai twin 共享)。
|
||||
- **请求使用原始 `fetch`,而非 `@cordisjs/plugin-http`**:没有共享 proxy/拦截配置;采用暂缓到第二个适配器需要该功能时(`TODO(http)`)。
|
||||
- **序列化会将 user 与工具结果内容展平为文本块**:会跳过插件添加的块类型,空工具输出会以字面 `(no output)` 通过协议发送。
|
||||
@@ -26,6 +26,7 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-credentials": "^0.0.1",
|
||||
"@deepseek-ai/dsh-environment": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-settings": "^0.0.1",
|
||||
@@ -38,6 +39,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-credentials": "workspace:^",
|
||||
"@deepseek-ai/dsh-environment": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-settings": "workspace:^",
|
||||
|
||||
@@ -49,12 +49,11 @@ export interface DeepSeekConnectionOptions {
|
||||
/** Endpoint base; `/chat/completions` is appended. */
|
||||
baseURL: string
|
||||
/**
|
||||
* Literal API key of this same resolution, when the configuration carried
|
||||
* one. Travelling with the endpoint is the point: a request can never pair
|
||||
* one generation's URL with another generation's secret.
|
||||
* Credential reference of this same resolution, resolved per request.
|
||||
* Travelling with the endpoint is the point: a request can never pair one
|
||||
* generation's URL with another generation's secret. Configuration carries
|
||||
* only this name — a literal key is not a configuration value.
|
||||
*/
|
||||
apiKey?: string
|
||||
/** Credential reference of this same resolution, resolved per request when no literal key exists. */
|
||||
apiKeyEnv: CredentialRef
|
||||
/** Request defaults applied to every call (thinking mode, effort). */
|
||||
defaults: RequestDefaults
|
||||
|
||||
@@ -13,9 +13,10 @@
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { assertUsableApiKey, LlmError, normalizeApiKey, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm'
|
||||
import { assertUsableApiKey, LlmError, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm'
|
||||
import type { RetryPolicyConfig } from '@deepseek-ai/dsh-llm'
|
||||
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
||||
import { environmentOf, type EnvironmentSnapshot } from '@deepseek-ai/dsh-environment'
|
||||
import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
import {
|
||||
@@ -58,17 +59,9 @@ const DEFAULT_MODELS: DeepSeekCatalogModel[] = [
|
||||
* reasoning effort resolves to `high`.
|
||||
*/
|
||||
export interface Config {
|
||||
/**
|
||||
* Trimmed literal API key; whitespace-only is absent, so it resolves through
|
||||
* {@link apiKeyEnv} like an omitted one. Prefer {@link apiKeyEnv} to keep
|
||||
* secrets out of configuration files. {@link resolveAdapterOptions} also
|
||||
* format-checks what remains: a value no HTTP header can carry fails there
|
||||
* rather than inside `fetch`.
|
||||
*/
|
||||
apiKey?: string
|
||||
/** Credential reference (environment-variable name) resolved per request; defaults to `DEEPSEEK_API_KEY`. */
|
||||
apiKeyEnv?: string
|
||||
/** Endpoint base; falls back to $DEEPSEEK_BASE_URL, then the public API. */
|
||||
/** Endpoint base; falls back to $DEEPSEEK_BASE_URL from a trusted environment layer, then the public API. */
|
||||
baseURL?: string
|
||||
/** Deployment thinking policy; `disabled` limits every conversation request to `off`. */
|
||||
thinking?: 'enabled' | 'disabled'
|
||||
@@ -95,7 +88,6 @@ const catalogModel: z<DeepSeekCatalogModel> = z.object({
|
||||
})
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
apiKey: z.string().role('secret'),
|
||||
apiKeyEnv: z.string().role('credential-ref').default(DEFAULT_API_KEY_ENV),
|
||||
baseURL: z.string(),
|
||||
thinking: z.union(['enabled', 'disabled']),
|
||||
@@ -110,6 +102,9 @@ export const Config: z<Config> = z.object({
|
||||
/** Public API default; the internal endpoint comes from $DEEPSEEK_BASE_URL. */
|
||||
export const PUBLIC_BASE_URL = 'https://api.deepseek.com'
|
||||
|
||||
/** Environment variable naming this provider's endpoint, honored only from trusted layers. */
|
||||
const BASE_URL_ENV = 'DEEPSEEK_BASE_URL'
|
||||
|
||||
/**
|
||||
* One resolution's complete request facts. Connection and credential facts
|
||||
* are one value on purpose: a snapshot the resolver rejects keeps the whole
|
||||
@@ -156,9 +151,13 @@ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): Dee
|
||||
* every default and bound is re-judged here — for the composition entry at
|
||||
* load (fail loud) and for each settings snapshot at its first use.
|
||||
* @param config - raw plugin config or resolved settings snapshot.
|
||||
* @param environment - this run's environment layers, or `undefined` outside
|
||||
* the product CLI. Every layer may supply an endpoint: the product trusts the
|
||||
* project it is launched in, so a checkout can point its own agent at the
|
||||
* gateway that checkout is meant to use.
|
||||
* @returns validated connection facts plus the credential reference.
|
||||
*/
|
||||
export function resolveAdapterOptions(config: Config): ResolvedDeepSeekOptions {
|
||||
export function resolveAdapterOptions(config: Config, environment?: EnvironmentSnapshot): ResolvedDeepSeekOptions {
|
||||
if (config.thinking === 'disabled'
|
||||
&& config.reasoningEffort !== undefined
|
||||
&& config.reasoningEffort !== 'off') {
|
||||
@@ -180,27 +179,11 @@ export function resolveAdapterOptions(config: Config): ResolvedDeepSeekOptions {
|
||||
`llm-deepseek: streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`,
|
||||
)
|
||||
}
|
||||
// An absent apiKey is not a failure: it falls through to apiKeyEnv below.
|
||||
// A supplied one must be usable, so a malformed literal fails here beside
|
||||
// the other beyond-schema bounds instead of inside `fetch`.
|
||||
// Absence is not a failure, and a blank literal is absence: both resolve
|
||||
// through apiKeyEnv below, which is this adapter's defined fallback. (The
|
||||
// pi-ai adapter refuses a blank one instead, because there absence selects a
|
||||
// different authentication mode rather than a different source for the same
|
||||
// key.) What a literal cannot be is unusable: a value no HTTP header can
|
||||
// carry fails here beside the other beyond-schema bounds, not inside `fetch`.
|
||||
let apiKey: string | undefined
|
||||
if (config.apiKey !== undefined) {
|
||||
const checked = normalizeApiKey(config.apiKey)
|
||||
if (!checked.ok && checked.reason === 'illegalCharacters') {
|
||||
throw new Error('llm-deepseek: apiKey contains characters no HTTP header can carry; paste the raw key only')
|
||||
}
|
||||
apiKey = checked.ok ? checked.value : undefined
|
||||
}
|
||||
return {
|
||||
...apiKey === undefined ? {} : { apiKey },
|
||||
apiKeyEnv: credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV),
|
||||
baseURL: config.baseURL ?? process.env.DEEPSEEK_BASE_URL ?? PUBLIC_BASE_URL,
|
||||
baseURL: config.baseURL
|
||||
?? environment?.get(BASE_URL_ENV)?.value
|
||||
?? PUBLIC_BASE_URL,
|
||||
defaults: {
|
||||
thinking: config.thinking,
|
||||
reasoningEffort: config.reasoningEffort,
|
||||
@@ -221,7 +204,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
const raw = current()
|
||||
if (raw === lastRaw && lastGood !== undefined) return lastGood
|
||||
try {
|
||||
const next = resolveAdapterOptions(raw)
|
||||
const next = resolveAdapterOptions(raw, environmentOf(ctx))
|
||||
lastRaw = raw
|
||||
lastGood = next
|
||||
return next
|
||||
@@ -241,22 +224,22 @@ export function apply(ctx: Context, config: Config): void {
|
||||
const resolveApiKey = async (connection: ResolvedDeepSeekOptions): Promise<string> => {
|
||||
// Every credential fact comes from the caller's snapshot, so a rejected
|
||||
// settings generation cannot leak its key onto the previous endpoint.
|
||||
if (connection.apiKey !== undefined) return connection.apiKey
|
||||
const ref = connection.apiKeyEnv
|
||||
const credentials = ctx.get('credentials')
|
||||
if (credentials !== undefined) {
|
||||
const hit = await credentials.resolve(ref)
|
||||
if (hit !== undefined) return assertUsableApiKey(hit.value, 'llm-deepseek', ref)
|
||||
} else {
|
||||
// Without the seam, keep the historical ambient fallback so a plain
|
||||
// cordis.yml composition works from the environment alone.
|
||||
const ambient = process.env[ref]
|
||||
if (ambient !== undefined && ambient.length > 0) return assertUsableApiKey(ambient, 'llm-deepseek', ref)
|
||||
// Without the seam there is no managed store to rank against, so the
|
||||
// environment is the whole credential plane.
|
||||
const ambient = environmentOf(ctx).get(ref)
|
||||
if (ambient !== undefined && ambient.value.length > 0) {
|
||||
return assertUsableApiKey(ambient.value, 'llm-deepseek', ref)
|
||||
}
|
||||
}
|
||||
throw new LlmError(
|
||||
`llm-deepseek: no API key for provider route "${PROVIDER}"; store ${ref} through the credentials`
|
||||
+ ` service (the web Models page writes it), export ${ref} in the launching environment, or — as a`
|
||||
+ ' last resort — set a literal "apiKey" in the llm-deepseek settings section',
|
||||
+ ` service (the web Models page writes it), or export ${ref} in the launching environment`,
|
||||
'MISSING_CREDENTIAL',
|
||||
)
|
||||
}
|
||||
|
||||
@@ -62,14 +62,16 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', ()
|
||||
if (key === undefined) throw new Error('e2e ran without DEEPSEEK_API_KEY')
|
||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-e2e-credentials-'))
|
||||
try {
|
||||
await writeFile(join(dir, '.env'), `DEEPSEEK_API_KEY=${key}\n`, { mode: 0o600 })
|
||||
// JSON.stringify quotes the value: YAML is a JSON superset, so a real
|
||||
// key survives whatever characters it happens to carry.
|
||||
await writeFile(join(dir, '.credentials.yaml'), `DEEPSEEK_API_KEY: ${JSON.stringify(key)}\n`, { mode: 0o600 })
|
||||
// Scrub the ambient variable so only the credential seam can supply the
|
||||
// key: this request proves the per-request resolution path end to end.
|
||||
vi.stubEnv('DEEPSEEK_API_KEY', '')
|
||||
const ctx = new Context()
|
||||
contexts.push(ctx)
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false })
|
||||
await ctx.plugin(CredentialsLocal, { path: join(dir, '.credentials.yaml'), watch: false })
|
||||
await ctx.plugin(LlmDeepSeek, {})
|
||||
|
||||
const result = await assemble(ctx, {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { createEnvironmentSnapshot } from '@deepseek-ai/dsh-environment'
|
||||
import LlmService, { createUserMessage,
|
||||
CONTEXT_WINDOW_EXCEEDED_CODE,
|
||||
ProviderRequestId,
|
||||
@@ -23,9 +24,12 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
async function harness(baseURL: string, config: object = {}) {
|
||||
// Configuration carries only the reference; the key comes from the
|
||||
// environment, which is the whole credential plane without a mounted seam.
|
||||
vi.stubEnv('DEEPSEEK_API_KEY', 'test-key')
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmDeepSeek, { apiKey: 'test-key', baseURL, ...config })
|
||||
await ctx.plugin(LlmDeepSeek, { baseURL, ...config })
|
||||
return ctx
|
||||
}
|
||||
|
||||
@@ -565,7 +569,6 @@ describe('plugin registration and config', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
const fiber = await ctx.plugin(LlmDeepSeek, {
|
||||
apiKey: 'k',
|
||||
baseURL: server.url,
|
||||
})
|
||||
expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }])
|
||||
@@ -584,7 +587,6 @@ describe('plugin registration and config', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmDeepSeek, {
|
||||
apiKey: 'k',
|
||||
baseURL: 'http://127.0.0.1:1',
|
||||
retryPolicy: {
|
||||
mode: 'always',
|
||||
@@ -603,7 +605,7 @@ describe('plugin registration and config', () => {
|
||||
it('owns the deepseek provider and advertises the default models', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmDeepSeek, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
|
||||
await ctx.plugin(LlmDeepSeek, { baseURL: 'http://127.0.0.1:1' })
|
||||
expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }])
|
||||
await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([
|
||||
{ provider: 'deepseek-official', id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', inputModalities: ['text'] },
|
||||
@@ -631,7 +633,6 @@ describe('plugin registration and config', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmDeepSeek, {
|
||||
apiKey: 'k',
|
||||
baseURL: 'http://127.0.0.1:1',
|
||||
reasoningEffort: effort,
|
||||
})
|
||||
@@ -652,7 +653,6 @@ describe('plugin registration and config', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmDeepSeek, {
|
||||
apiKey: 'k',
|
||||
baseURL: 'http://127.0.0.1:1',
|
||||
thinking: 'disabled',
|
||||
reasoningEffort: 'off',
|
||||
@@ -672,7 +672,6 @@ describe('plugin registration and config', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await expect(ctx.plugin(LlmDeepSeek, {
|
||||
apiKey: 'k',
|
||||
baseURL: 'http://127.0.0.1:1',
|
||||
thinking: 'disabled',
|
||||
reasoningEffort,
|
||||
@@ -699,17 +698,10 @@ describe('plugin registration and config', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes a literal API key and treats whitespace as absent', () => {
|
||||
expect(resolveAdapterOptions({ apiKey: ' key ' }).apiKey).toBe('key')
|
||||
const whitespace = resolveAdapterOptions({ apiKey: ' \t ', apiKeyEnv: 'CUSTOM_API_KEY' })
|
||||
expect(whitespace.apiKey).toBeUndefined()
|
||||
expect(whitespace.apiKeyEnv).toBe('CUSTOM_API_KEY')
|
||||
})
|
||||
|
||||
it('uses the default model catalog when apply is called directly', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
LlmDeepSeek.apply(ctx, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
|
||||
LlmDeepSeek.apply(ctx, { baseURL: 'http://127.0.0.1:1' })
|
||||
await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([
|
||||
{ provider: 'deepseek-official', id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', inputModalities: ['text'] },
|
||||
{ provider: 'deepseek-official', id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', inputModalities: ['text'] },
|
||||
@@ -720,7 +712,6 @@ describe('plugin registration and config', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmDeepSeek, {
|
||||
apiKey: 'k',
|
||||
baseURL: 'http://127.0.0.1:1',
|
||||
models: [
|
||||
{ id: 'private-fast', contextWindow: 32_000 },
|
||||
@@ -754,7 +745,6 @@ describe('plugin registration and config', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmDeepSeek, {
|
||||
apiKey: 'k',
|
||||
baseURL: 'http://127.0.0.1:1',
|
||||
defaultContextWindow: 256_000,
|
||||
models: [
|
||||
@@ -775,7 +765,6 @@ describe('plugin registration and config', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmDeepSeek, {
|
||||
apiKey: 'k',
|
||||
baseURL: 'http://127.0.0.1:1',
|
||||
models: [],
|
||||
})
|
||||
@@ -792,7 +781,6 @@ describe('plugin registration and config', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await expect(ctx.plugin(LlmDeepSeek, {
|
||||
apiKey: 'k',
|
||||
baseURL: 'http://127.0.0.1:1',
|
||||
models: [...models],
|
||||
})).rejects.toThrow(message)
|
||||
@@ -824,7 +812,6 @@ describe('plugin registration and config', () => {
|
||||
await ctx.plugin(LlmService)
|
||||
expect(() => {
|
||||
LlmDeepSeek.apply(ctx, {
|
||||
apiKey: 'k',
|
||||
baseURL: 'http://127.0.0.1:1',
|
||||
models: [{ id: 'invalid-context', contextWindow: 0 }],
|
||||
})
|
||||
@@ -841,7 +828,6 @@ describe('plugin registration and config', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await expect(ctx.plugin(LlmDeepSeek, {
|
||||
apiKey: 'k',
|
||||
baseURL: 'http://127.0.0.1:1',
|
||||
defaultContextWindow,
|
||||
})).rejects.toThrow(/defaultContextWindow/)
|
||||
@@ -858,7 +844,6 @@ describe('plugin registration and config', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await expect(ctx.plugin(LlmDeepSeek, {
|
||||
apiKey: 'k',
|
||||
baseURL: 'http://127.0.0.1:1',
|
||||
maxTokens,
|
||||
})).rejects.toThrow(/maxTokens/)
|
||||
@@ -886,13 +871,14 @@ describe('plugin registration and config', () => {
|
||||
await expect(ctx.llm.listModels('deepseek-official')).resolves.toHaveLength(2)
|
||||
const first = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
|
||||
expect(first.finish).toMatchObject({ kind: 'error', failure: { code: 'MISSING_CREDENTIAL' } })
|
||||
// The guidance leads with the credential store — the path that keeps the
|
||||
// secret out of configuration files — and mentions a literal key last.
|
||||
// The guidance leads with the managed credential store.
|
||||
const second = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
|
||||
expect(second.finish.kind).toBe('error')
|
||||
if (second.finish.kind !== 'error') throw new Error('expected an error finish')
|
||||
// The guidance names both places a credential can come from, and nothing
|
||||
// else: configuration carries the reference, never a literal key.
|
||||
expect(second.finish.failure.message)
|
||||
.toMatch(/store DEEPSEEK_API_KEY through the credentials service.*as a last resort.*"apiKey"/s)
|
||||
.toMatch(/store DEEPSEEK_API_KEY through the credentials service.*export DEEPSEEK_API_KEY/s)
|
||||
})
|
||||
|
||||
it('reads the ambient variable when no credentials seam is mounted', async () => {
|
||||
@@ -928,13 +914,33 @@ describe('plugin registration and config', () => {
|
||||
it('uses DEEPSEEK_BASE_URL when config omits baseURL', async () => {
|
||||
const server = await mockServer([{ kind: 'sse', events: textEvents }])
|
||||
vi.stubEnv('DEEPSEEK_BASE_URL', server.url)
|
||||
vi.stubEnv('DEEPSEEK_API_KEY', 'test-key')
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmDeepSeek, { apiKey: 'k' })
|
||||
await ctx.plugin(LlmDeepSeek, {})
|
||||
await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })
|
||||
expect(server.requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
|
||||
it('takes DEEPSEEK_BASE_URL from any environment layer, with explicit config still on top', () => {
|
||||
const trusted = createEnvironmentSnapshot([
|
||||
{ source: 'user-env', path: '/home/.dsh/.env', values: { DEEPSEEK_BASE_URL: 'https://user.example' } },
|
||||
])
|
||||
expect(resolveAdapterOptions({}, trusted).baseURL).toBe('https://user.example')
|
||||
// The product trusts the project it is launched in, so a checkout can
|
||||
// point its own agent at the gateway that checkout is meant to use.
|
||||
const project = createEnvironmentSnapshot([
|
||||
{ source: 'project-env', path: '/work/.env', values: { DEEPSEEK_BASE_URL: 'https://project.example' } },
|
||||
])
|
||||
expect(resolveAdapterOptions({}, project).baseURL).toBe('https://project.example')
|
||||
// An explicitly configured endpoint outranks every environment layer, so a
|
||||
// stale shell value cannot rewrite a deployment's own gateway.
|
||||
const shell = createEnvironmentSnapshot([
|
||||
{ source: 'process', values: { DEEPSEEK_BASE_URL: 'https://stale.example' } },
|
||||
])
|
||||
expect(resolveAdapterOptions({ baseURL: 'https://gateway.internal' }, shell).baseURL).toBe('https://gateway.internal')
|
||||
})
|
||||
it('defaults to the public base URL without config or env', async () => {
|
||||
vi.stubEnv('DEEPSEEK_API_KEY', 'k')
|
||||
vi.stubEnv('DEEPSEEK_BASE_URL', undefined)
|
||||
@@ -975,12 +981,10 @@ describe('plugin registration and config', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await expect(ctx.plugin(LlmDeepSeek, {
|
||||
apiKey: 'k',
|
||||
baseURL: 'http://127.0.0.1:1',
|
||||
streamIdleTimeoutMs: 0,
|
||||
})).rejects.toThrow(/streamIdleTimeoutMs/)
|
||||
await expect(ctx.plugin(LlmDeepSeek, {
|
||||
apiKey: 'k',
|
||||
baseURL: 'http://127.0.0.1:1',
|
||||
streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1,
|
||||
})).rejects.toThrow(/streamIdleTimeoutMs/)
|
||||
@@ -991,45 +995,9 @@ describe('plugin registration and config', () => {
|
||||
await ctx.plugin(LlmService)
|
||||
|
||||
await expect(ctx.plugin(LlmDeepSeek, {
|
||||
apiKey: 'k',
|
||||
baseURL: 'http://127.0.0.1:1',
|
||||
retryPolicy: { mode: 'normal', maxRetries: -1 },
|
||||
})).rejects.toThrow(/retryPolicy/)
|
||||
expect(ctx.llm.listProviders()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('API key format', () => {
|
||||
it('trims a padded literal apiKey', () => {
|
||||
expect(resolveAdapterOptions({ apiKey: ' sk-abc ' }).apiKey).toBe('sk-abc')
|
||||
})
|
||||
|
||||
it('leaves an omitted apiKey absent so apiKeyEnv still resolves it', () => {
|
||||
expect(resolveAdapterOptions({}).apiKey).toBeUndefined()
|
||||
})
|
||||
|
||||
it('treats a whitespace-only literal apiKey as absent, not as a failure', () => {
|
||||
// This adapter's absence has a defined fallback, so a blank literal
|
||||
// resolves through apiKeyEnv like an omitted one. (llm-pi-ai refuses a
|
||||
// blank one instead: there, absence selects provider-native or OAuth
|
||||
// authentication rather than a different source for the same key.)
|
||||
const resolved = resolveAdapterOptions({ apiKey: ' ', apiKeyEnv: 'CUSTOM_API_KEY' })
|
||||
expect(resolved.apiKey).toBeUndefined()
|
||||
expect(resolved.apiKeyEnv).toBe('CUSTOM_API_KEY')
|
||||
})
|
||||
|
||||
it('rejects a literal apiKey no header can carry', () => {
|
||||
expect(() => resolveAdapterOptions({ apiKey: 'sk-\u{1F600}' }))
|
||||
.toThrow(/no HTTP header can carry/)
|
||||
})
|
||||
|
||||
it('never echoes the key in the rejection', () => {
|
||||
const secret = 'sk-\u{1F600}supersecret'
|
||||
expect(() => resolveAdapterOptions({ apiKey: secret })).toThrow()
|
||||
try {
|
||||
resolveAdapterOptions({ apiKey: secret })
|
||||
} catch (error) {
|
||||
expect((error as Error).message).not.toContain('supersecret')
|
||||
}
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user