Merge branch 'worktree-llm-dynamic-config' into worktree-llm-web-config

# Conflicts:
#	docs/cordis-catalog/services.md
#	docs/core-data-structures/core.i18n.yaml
#	docs/event-producer-consumer.md
#	examples/headless-agent/tests/headless.snapshot.ts
#	examples/headless-agent/tests/snapshots/missing-credential/stream-json.expected.jsonl
#	packages/llm/llm-deepseek/README.i18n.yaml
#	packages/llm/llm-deepseek/src/index.ts
#	packages/llm/llm-pi-ai/README.i18n.yaml
#	packages/llm/llm-pi-ai/src/index.ts
#	packages/llm/llm/README.i18n.yaml
#	packages/llm/llm/src/index.ts
This commit is contained in:
Yichen Jiang
2026-07-30 17:22:44 +08:00
60 changed files with 1320 additions and 320 deletions

View File

@@ -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: 67baec4b70d0c754f22573d87fb4492de5ca16a4
2026-07-29-request-level-llm-config-credentials.zh.md: 36182b77f4494c99b0fb08107f865f85322ece6c
2026-07-29-request-level-llm-config-credentials.md: f12a2496a767decc3ce2b065f6be03009aec8992
2026-07-29-request-level-llm-config-credentials.zh.md: 99fd90013a24746962ca02a5f4f18cdccd53f71a

View File

@@ -26,4 +26,4 @@ The [settings seam](2026-07-28-user-settings-seam.md) shipped without a producti
## Consequences
Onboarding is restart-free end to end (pinned by the `missing-credential` headless snapshot and the credentials-rotation composition tests): boot keyless, browse the catalog, store the key, prompt again. The demos mount `settings-local` + `credentials-local` by default and inline no `!!js` key plumbing. `runLoaderSmoke` gained `expectedExitCode` so a designed failure surface can be pinned rather than masked. Deferred: the wire/UI surface must redact `role('secret')` fields before any RPC exposes `describe()`, settings-layer arrays still replace wholesale (the deepseek `models` list), and a settings section cannot remove a composition-provided pi-ai route (only override or extend).
Onboarding is restart-free end to end (pinned by the `missing-credential` headless snapshot and the credentials-rotation composition tests): boot keyless, browse the catalog, store the key, prompt again. The demos mount `settings-local` + `credentials-local` by default and inline no `!!js` key plumbing. `runLoaderSmoke` gained `expectedExitCode` so a designed failure surface can be pinned rather than masked. Deferred: the wire/UI surface must redact `role('secret')` fields before any RPC exposes `describe()`, settings-layer arrays still replace wholesale (the deepseek `models` list), and a settings section cannot remove a composition-provided pi-ai route (only override or extend). Review of this seam later reworked where the store lives and who may read it, made one request resolve one configuration generation, and made route replacement atomic ([credential boundaries note](2026-07-30-credential-boundaries-and-atomic-registration.md)).

View File

@@ -26,4 +26,4 @@ Status: implemented
## 后果
上手流程端到端免重启(由 `missing-credential` headless 快照与凭据轮换组合测试固定):无密钥启动、浏览 catalog、存入密钥、再次发起提示。demo 默认挂载 `settings-local` + `credentials-local`,不再内联任何 `!!js` 密钥接线。`runLoaderSmoke` 新增 `expectedExitCode`使按设计出现的失败面可以被固定而非被掩盖。延后事项wire/UI 面在任何 RPC 暴露 `describe()` 之前必须对 `role('secret')` 字段脱敏settings 层的数组仍整体替换deepseek 的 `models` 列表settings 分节无法移除组合提供的 pi-ai 路由(只能覆盖或扩展)。
上手流程端到端免重启(由 `missing-credential` headless 快照与凭据轮换组合测试固定):无密钥启动、浏览 catalog、存入密钥、再次发起提示。demo 默认挂载 `settings-local` + `credentials-local`,不再内联任何 `!!js` 密钥接线。`runLoaderSmoke` 新增 `expectedExitCode`使按设计出现的失败面可以被固定而非被掩盖。延后事项wire/UI 面在任何 RPC 暴露 `describe()` 之前必须对 `role('secret')` 字段脱敏settings 层的数组仍整体替换deepseek 的 `models` 列表settings 分节无法移除组合提供的 pi-ai 路由(只能覆盖或扩展)。对该 seam 的评审随后改造了存储的所在位置与谁可以读取它,让一个请求解析出一个配置世代,并使路由替换成为原子操作([credential boundaries note](2026-07-30-credential-boundaries-and-atomic-registration.md))。

View File

@@ -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-07-30-credential-boundaries-and-atomic-registration.md
2026-07-30-credential-boundaries-and-atomic-registration.md: 6fe5f554acbfd804db9625fcaa794d513c8799c4
2026-07-30-credential-boundaries-and-atomic-registration.zh.md: 3eb3b022064124aad2a389abba3063af4e2110fa

View File

@@ -0,0 +1,36 @@
# Agent Note: credential boundaries, whole-snapshot requests, and atomic route registration
Status: implemented
English | [中文](2026-07-30-credential-boundaries-and-atomic-registration.zh.md)
> Scope: the third review round over the [request-level LLM configuration seam](2026-07-29-request-level-llm-config-credentials.md) — where a stored credential lives and who can read it, how one request's facts stay one generation, and how a route set changes without a window. Companion to the [settings write-path note](2026-07-30-settings-write-path-integrity.md), whose provider fixes this round applies to `credentials-local` and whose writer lock it promotes into `dsh-atomic-write`.
## Problem
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.
## 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 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.
**Route replacement is a registry operation, not a caller sequence.** `registerAdapter` returns a handle carrying `replace(providers)`: the candidate set is validated in full first (conflicts, names, provider metadata), then swapped in one synchronous section. A refused replacement leaves the previous routes registered and serving, and the caller's facts cache only advances after the registry actually holds the new set, so reverting to a working configuration re-applies. pi-ai's registration facts are sorted by provider, so a settings document that merely reorders its keys is no longer a route change.
**Contained publication for committed credential writes.** `Credentials.notifyUpdated` fans `credentials/updated` out one listener at a time; sync throws and async rejections are logged without changing the committed operation's outcome, and `INVARIANT`-coded failures rethrow after every listener ran — the same shape the settings seam uses for `settings/updated`. `installSettingsSection`'s cleanup now distinguishes its two triggers: a provider detaching still falls back to the composition entry and re-derives, while the consumer's own unload returns immediately instead of re-registering routes during teardown.
## Alternatives considered
- **A sandbox read-denial naming `$DSH_HOME/.env`** — implemented as a `readDenyPaths` policy field (a trailing SBPL `deny file-read* file-write*`, a `/dev/null` bwrap bind) and withdrawn on its own evidence. bwrap must create that bind's mount point inside a tree its profile has already made read-only, so it refuses the entire confinement whenever the parent directory is absent — every host that has not stored a credential yet, including a fresh install; Landlock cannot subtract from its own `/` read grant, so every confined call would report `partial` for a file it never hid. A protection that breaks confinement where it works and misreports it where it does not is worse than a documented absence. Denying the whole harness home was rejected earlier for a separate reason: it also covers `sessions/`, and `DSH_SESSION_JSONL` is a documented model-visible capability.
- **Removing `DSH_HOME` from the model's bash environment** — considered as defense in depth and rejected as theater with a real cost: the default home is a documented convention the agent can reconstruct, while the variable is how legitimate tooling finds harness state. There is no boundary here for it to complement; hiding the pointer would only make the absence harder to see.
- **Shipping the OS-keychain provider in this round** — it is the only design where the model's processes genuinely cannot read the secret, and it is a sibling package with three platform backends. Sizing it against the rest of this review round would have delayed every other fix; it is recorded as the deferred answer, not as a maybe.
- **A `replaceRegistration(previous, next)` service method** — the review's shape, but it makes the caller carry the previous handle and lets it pass a mismatched one. Hanging `replace` on the registration handle makes ownership structural: only the registration that holds routes can replace them.
## Consequences
`update()`-adjacent behavior gained documented failure modes: a credential write can now fail on the lock deadline or on an unparsable on-disk document, and `describe()` reports `writable: false` for multi-line entries it will not rewrite. `LlmAdapter` registrants keep working unchanged (the handle is still callable as the disposer), and `DeepSeekConnectionOptions` gained credential fields, so a programmatic constructor of the adapter must supply `apiKeyEnv`. Deferred: the OS-keychain credential provider, and per-value revision checks for two writers editing one reference (last-write-wins remains the documented resolution).

View File

@@ -0,0 +1,40 @@
# Agent Note: 凭据边界、按整份快照发起的请求与原子路由注册
Status: implemented
[English](2026-07-30-credential-boundaries-and-atomic-registration.md) | 中文
> 范围:对[请求级 LLM大语言模型配置 seam](2026-07-29-request-level-llm-config-credentials.md)的第三轮评审——存下来的凭据落在哪里、谁能读到它,一次请求的事实如何保持为同一代,以及一组路由如何在不留空窗的前提下更换。本 note 与 [settings 写路径 note](2026-07-30-settings-write-path-integrity.md) 配套:本轮把那篇 note 的提供方修复套用到 `credentials-local`,并把其中的写锁提升进 `dsh-atomic-write`。
## 问题
评审发现,凭据路径正在越过它自己划下的边界泄漏。已交付的各个面在 Cordis 启动之前就把 `$DSH_HOME/.env` 提升进了 `process.env`,于是下一次运行时,`credentials-local` 会把它自己存下的每个键都判成来自环境的只读启动覆盖:`describe()` 报告 `source: 'env'``writable: false``set`/`unset` 以被遮蔽为由拒绝,从 web 页面或 TUI 存入的密钥既无法轮换也无法删除,而适配器还在继续使用启动时捕获的那个值。
存储自身的写路径重演了同一轮评审在 settings-local 修掉的那些缺陷两条相互独立的链、从陈旧缓存渲染整份文件还叠加了编辑器自己的缺陷另一个键的带引号多行值内部的一条物理行会被读成赋值CRLF 行尾会退化成 LF多行条目报告 `writable: true``set` 总是抛错,`credentials/updated` 又在提交之后裸发,于是一个出错的观察者就能让一次已经落盘的写入看起来失败。
在读取一侧,文件的 `0600` 权限挡得住其他 OS 用户,却挡不住模型:它的 bash 与文件系统工具就以同一个用户身份运行。
与之并排的还有两个请求路径缺陷。DeepSeek 的按请求解析把连接事实保存在最后可用快照里,却仍从原始配置重新读取字面 `apiKey`,于是被 resolver 拒绝的那一代设置,照样能把自己的密钥送到上一代的端点上。配置了 `apiKeyEnv` 却解析不到值时pi-ai 会把 `undefined` 交给 SDK让 pi-ai 自己的环境发现拿一个毫不相干的提供方密钥完成鉴权——那是另一个租户,账单还悄悄记在它头上。而且它的路由替换是先释放旧注册、再创建新注册:只要有一条路由已被别的适配器占有,现有路由就会被全部丢掉,此后事实缓存可能与注册表中的事实相等,于是把配置改回可用状态也不会重新生效。
## 决策
**`$DSH_HOME/.env` 只归凭据提供方所有。**没有任何一个面会把它加载进 `process.env`。真正的启动环境,以及调用目录中由 bin 加载的 `.env`,仍然是那一层只读的环境来源,因此不挂载该提供方的组合,解析密钥的方式与从前完全一致,而存下的密钥跨重启仍然来源于文件、仍然可写——这一点由 Loader 组合中的一次真实重启来证明,而不是靠对 `describe()` 的单元断言。
**存下的凭据对模型没有边界,而 README 就是这么写的。**`0700` 目录下的 `0600` 挡得住其他 OS 用户;模型的 bash 与文件系统工具正是以同一用户身份运行而已交付的默认值不约束任何东西。harness 真正守住的更窄,也就照这个宽度写下来:没有任何一个面会把该文档提升进 `process.env`模型也从不会拿到它的解析后路径因此要拿到这个值需要刻意去读一条并未交给它的路径。OS 钥匙串keychain提供方——一个模型的进程根本读不到的存储——被记录为真正的答案而不是靠一个残缺的方案去暗示它。
**一次请求,一代设置。**DeepSeek 解析出的快照在端点旁一并携带凭据事实(字面密钥与引用),`resolveApiKey` 接收这份快照,而不再重新读取配置。被拒绝的那一代如今完全不再贡献任何东西。只有当一个 profile 完全没有点名凭据时pi-ai 才交给提供方原生的发现流程;配置了引用却解析不到,就以 `MISSING_CREDENTIAL` 失败,并点名该路由与该引用。启动时的凭据探测被删除:它可能在凭据服务挂载之前就运行,并把每一种失败都报成密钥缺失,而第一次请求本就会给出准确的错误。
**路由替换是注册表的操作,不是调用方的一串步骤。**`registerAdapter` 返回一个携带 `replace(providers)` 的句柄候选集合先被完整校验冲突、名称、提供方元数据再在一个同步区段内完成替换。被拒绝的替换会让先前的路由保持注册并继续服务而调用方的事实缓存只有在注册表确实持有新集合之后才会推进因此改回可用配置时会重新生效。pi-ai 的注册事实按提供方排序,因此仅仅调换键顺序的设置文档不再算作路由变更。
**已提交的凭据写入采用收容式发布。**`Credentials.notifyUpdated` 逐个监听器扇出 `credentials/updated`;同步抛错与异步 rejection 都只记日志,不改变已提交操作的结果,而带 `INVARIANT` 代码的失败会在每个监听器都运行完之后重抛——与 settings seam 处理 `settings/updated` 的形状相同。`installSettingsSection` 的清理现在会区分它的两个触发来源:提供方脱离时仍回退到组合的 entry 配置并重新推导,而消费方自身卸载时立即返回,不再在拆卸过程中重新注册路由。
## 曾考虑的替代方案
- **用沙箱点名拒读 `$DSH_HOME/.env`**——已按 `readDenyPaths` 策略字段实现过(末尾一条 SBPL `deny file-read* file-write*`、一条 `/dev/null` 的 bwrap bind又被它自己的证据推翻。bwrap 必须在自己 profile 已经置为只读的目录树内部创建该 bind 的挂载点因此只要父目录不存在它就会拒绝整次约束——那是每一台还没有存过凭据的主机包括全新安装Landlock 无法从它自己对 `/` 的读取授权中减去任何东西,于是每一次受限调用都会为一个它其实从未藏起的文件报 `partial`。一项在生效之处破坏约束、在不生效之处误报的保护,比一条写明的「没有保护」更糟。至于拒掉整个 harness home早先另有理由被否它同时覆盖 `sessions/`,而 `DSH_SESSION_JSONL` 是一项成文的、模型可见的能力。
- **把 `DSH_HOME` 从模型的 bash 环境中移除**——作为纵深防御考虑过,最终按「有真实代价的表演」不予采纳:默认 home 是 agent智能体能自行重建的成文约定而这个变量正是正当工具链定位 harness 状态的途径。这里并不存在一条需要它来补强的边界,藏起指针只会让这份缺席更难被看见。
- **本轮就交付 OS 钥匙串提供方**——只有这个设计能让模型的进程真正读不到机密而它是一个带三种平台后端的兄弟包package。把它与本轮评审的其余工作放在一起评估体量会拖慢其他每一项修复它被记录为那个延后的答案而不是一个「也许」。
- **做成 `replaceRegistration(previous, next)` 服务方法**——这是评审给出的形状,但它要求调用方自行携带上一个句柄,也允许它传入一个不匹配的句柄。把 `replace` 挂在注册句柄上,让归属关系变成结构性的:只有持有路由的那一项注册才能替换它们。
## 后果
`update()` 邻近的行为多了成文的失败模式:凭据写入现在可能因锁截止时间到期、或磁盘文档无法解析而失败,`describe()` 对它不会改写的多行条目报告 `writable: false``LlmAdapter` 的注册方无需改动即可继续工作(句柄本身仍可当作释放器调用),`DeepSeekConnectionOptions` 则新增了凭据字段,因此以编程方式构造该适配器必须提供 `apiKeyEnv`。延后事项OS 钥匙串凭据提供方,以及针对两个写方编辑同一引用的逐值修订号检查(后写胜出仍是成文的解决方式)。

View File

@@ -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/README.md
README.md: 93c36d18abd06bbd7a80c918f520b92489180395
README.zh.md: 85f4624a592eaf2ae44dc31fb4e18fb5657e62fd
README.md: 1decf018f53e55e6dde73d8b65963ab96e20122b
README.zh.md: eecf60ca6a1a543b6c8f71297eab036ca6b42815

View File

@@ -12,7 +12,7 @@ The TUI surface:
- resumes a persisted session with `dsh --resume <session-id>` and, when the Node host exposes `process.execve`, supplies the TUI's in-place handoff host: after selector preflight and current-session flush, the host disposes the app and replaces the process with a normalized `dsh --resume <id>`; runtimes without process replacement keep the displayed command fallback. The flag provides the id on the boot context under `RESUME_SESSION_ID_KEY` (no environment variable), which the shipped config reads through `!!js`, and a missing or unreadable id fails loud instead of creating a fresh session;
- treats the **invoking directory** as the workspace — sessions, relative paths, and workspace instructions resolve from the cwd;
- tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it;
- applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree.
- applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `config.yaml` patches the booted tree, while `.env` there is the credential provider's own store (never hoisted into the environment, so keys stay rotatable). Environment precedence is ambient > project `.env`.
The Web and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root <path>` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opt into first-message model titles. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`).

View File

@@ -12,7 +12,7 @@ TUI 界面:
- 使用 `dsh --resume <session-id>` 恢复已持久化会话。当 Node 宿主公开 `process.execve` 时,还会提供 TUI 的原地移交宿主:选择器预检并刷新当前会话后,宿主会释放应用,并以规范化的 `dsh --resume <id>` 替换进程;不支持进程替换的运行时保留屏幕上显示的命令回退。该标志通过 `RESUME_SESSION_ID_KEY` 在启动上下文中提供 id不使用环境变量已交付的配置通过 `!!js` 读取它;缺失或无法读取的 id 会明确报错,而不会创建新会话;
-**调用目录** 视为 workspace会话、相对路径和 workspace 指令都从 cwd 解析;
- 告知 agent 自身源码所在位置:启动后添加一个命名此 harness checkout 的提示词段。该路径从启动器的真实路径解析,因此在 PATH 符号链接和任意 cwd 下仍然有效,使自指的 `cordis` 工具集可以读取并修改它;
- 应用 `~/.dsh` 中的个人覆盖(参见 [app-boot 的个人配置](../../packages/ui/app-boot/README.md#personal-config)`.env` 填补环境缺口(环境中已有的值 > 项目 `.env` > 个人 `.env``config.yaml` 则修补已启动的树
- 应用 `~/.dsh` 中的个人覆盖(参见 [app-boot 的个人配置](../../packages/ui/app-boot/README.md#personal-config)`config.yaml` 修补已启动的树,而那里的 `.env` 是凭据 provider 自己的存储(绝不会被提升进环境,因此密钥始终可轮换)。环境优先级为环境中已有的值 > 项目 `.env`
Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root <path>` 覆盖,否则会在该根目录下创建具名 Workspace它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle`pnpm run build && pnpm run build:web`)。

View File

@@ -1,10 +1,11 @@
/**
* AppCLIEntry — the pre-cordis boot glue the config-tree dsh surfaces share
* (`dsh web` and `dsh -p` boot the one composition; TUI migrates later).
* Everything here is what must exist before the Loader runs: layered env,
* the patch composition over the shipped cordis.yml (profile json + CLI
* flags + the resolved frontend dist), and the fail-loud triple after the
* tree settles.
* Everything here is what must exist before the Loader runs: the patch
* composition over the shipped cordis.yml (profile json + CLI flags + the
* resolved frontend dist) and the fail-loud triple after the tree settles.
* The environment is what the bin already loaded (ambient plus the invoking
* directory's `.env`); `$DSH_HOME/.env` belongs to the credential provider.
*/
import { readFileSync } from 'node:fs'
@@ -17,7 +18,7 @@ import type { FiberState } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import Include, { type PatchOptions } from '@cordisjs/plugin-include'
import yaml from 'js-yaml'
import { assertEntriesLoaded, installFailLoud, loadEnv } from '@deepseek-ai/dsh-app-boot'
import { assertEntriesLoaded, installFailLoud } from '@deepseek-ai/dsh-app-boot'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
// Empty type import carries the httpServer Context merge for the port read below.
import type {} from '@deepseek-ai/dsh-host-webserver'
@@ -147,7 +148,6 @@ export class AppCLIEntry {
* @returns the settled root context and the listening port.
*/
async run(): Promise<{ ctx: Context; port: number }> {
this.loadEnvLayers()
this.composePatches()
await this.bootTree()
this.assertBoot()
@@ -157,11 +157,6 @@ export class AppCLIEntry {
return { ctx: this.ctx, port }
}
/** Layered .env: ambient > cwd (bin already loaded) > $DSH_HOME (loadEnvFile never overrides). */
private loadEnvLayers(): void {
loadEnv('dsh', resolveDshHome())
}
/**
* Compose the patch set from the non-yml config sources: computed
* engineering defaults (the global session root), profile json (user

View File

@@ -1,9 +1,10 @@
/**
* `dsh` default surface — the interactive TUI coding agent. Boots the shipped
* tui-agent config (or the `--config` override) with the personal overlay
* from the Harness home (`~/.dsh`): its `.env` fills environment gaps (precedence:
* ambient environment, then the invoking directory's `.env`, then the personal one)
* and its `config.yaml` patches the booted tree. The workspace is the invoking
* from the Harness home (`~/.dsh`): its `config.yaml` patches the booted tree.
* The environment layers are the ambient one and the invoking directory's
* `.env`; `$DSH_HOME/.env` stays the credential provider's own store and is
* never hoisted into `process.env`. The workspace is the invoking
* directory: sessions, relative paths, and workspace instructions resolve from
* the cwd, so `dsh` acts on whatever project it is launched in. After boot, the
* agent's system prompt is told the path to this harness checkout so it can find
@@ -17,12 +18,10 @@ import {
addHarnessSourceSection,
boot,
installFailLoud,
loadEnv,
loadPersonalPatches,
RESUME_SESSION_ID_KEY,
resolveConfigPath,
} from '@deepseek-ai/dsh-app-boot'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
import type { Context } from 'cordis'
import {
TUI_GOODBYE_MESSAGE_KEY,
@@ -63,9 +62,11 @@ export async function runTui(config: string | undefined, resumeSessionId: string
process.exit(1)
}
installFailLoud(NAME)
// The bin already loaded the invoking directory's .env; the personal .env
// only fills what is still unset (process.loadEnvFile never overrides).
loadEnv(NAME, resolveDshHome())
// The bin already loaded the invoking directory's .env as the ambient
// layer. `$DSH_HOME/.env` is deliberately NOT loaded here: it is the
// credential provider's own writable store, and hoisting it into
// process.env would make every stored key look like a read-only launch
// override on the next run, blocking rotation from the TUI and the web page.
process.env.DSH_BUNDLED_SKILL_DIR = join(SOURCE_ROOT, 'skills')
// The in-place `/resume` handoff re-execs `dsh` with a normalized `--resume`
// flag, so the resumed process rehydrates through this same intake. The host

View File

@@ -416,7 +416,7 @@ export interface Config {
}
```
Source: [`packages/credentials/credentials-local/src/index.ts:24`](../packages/credentials/credentials-local/src/index.ts)
Source: [`packages/credentials/credentials-local/src/index.ts:26`](../packages/credentials/credentials-local/src/index.ts)
## `@deepseek-ai/dsh-fs-local`
@@ -656,7 +656,7 @@ export interface DeepSeekCatalogModel {
Depends on: [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts)
Source: [`packages/llm/llm-deepseek/src/index.ts:49`](../packages/llm/llm-deepseek/src/index.ts)
Source: [`packages/llm/llm-deepseek/src/index.ts:50`](../packages/llm/llm-deepseek/src/index.ts)
## `@deepseek-ai/dsh-llm-pi-ai`

View File

@@ -443,13 +443,18 @@ Source: [`packages/ui/commands/src/index.ts:154`](../../packages/ui/commands/src
### `credentials/updated` — emit
Committed change to a provider-managed credential source: a `set`, an `unset`, or an external edit observed in storage. Ambient process-environment changes are not observable and never emit.
Committed change to a provider-managed credential source: a `set`, an `unset`, or an external edit observed in storage. Ambient process-environment changes are not observable and never emit. Listener failures are contained and logged — a sync throw and an async rejection alike — without changing the committed operation's outcome, except `INVARIANT`-coded failures, which rethrow after every listener ran; that rethrow reaches the emitter only from synchronous listeners, so invariant checks on this event must not be async functions.
```ts cordis-catalog
/**
* Committed change to a provider-managed credential source: a `set`, an
* `unset`, or an external edit observed in storage. Ambient
* process-environment changes are not observable and never emit.
* process-environment changes are not observable and never emit. Listener
* failures are contained and logged — a sync throw and an async rejection
* alike — without changing the committed operation's outcome, except
* `INVARIANT`-coded failures, which rethrow after every listener ran;
* that rethrow reaches the emitter only from synchronous listeners, so
* invariant checks on this event must not be async functions.
* @param ref - the reference whose stored value changed.
* @mode emit
*/
@@ -458,7 +463,7 @@ Committed change to a provider-managed credential source: a `set`, an `unset`, o
Types: [CredentialRef](../core-data-structures/credentials.md)
Source: [`packages/credentials/credentials/src/index.ts:62`](../../packages/credentials/credentials/src/index.ts)
Source: [`packages/credentials/credentials/src/index.ts:67`](../../packages/credentials/credentials/src/index.ts)
## `domain/*`

View File

@@ -532,7 +532,7 @@ abstract unset(ref: CredentialRef): Promise<void>
Types: [CredentialInfo](../core-data-structures/credentials.md) · [CredentialRef](../core-data-structures/credentials.md) · [ResolvedCredential](../core-data-structures/credentials.md)
Source: [`packages/credentials/credentials/src/index.ts:72`](../../packages/credentials/credentials/src/index.ts)
Source: [`packages/credentials/credentials/src/index.ts:77`](../../packages/credentials/credentials/src/index.ts)
## `ctx.directoryPicker` — `DirectoryPicker` (abstract seam)
@@ -790,9 +790,9 @@ The abstract `llm` service: an adapter registry plus a streaming model-call surf
* Disposed with the fiber.
* @param providers - every provider route this adapter should serve.
* @param adapter - the adapter that streams calls for those providers.
* @returns the disposer that unregisters all of them.
* @returns the disposer, carrying {@link AdapterRegistrationHandle.replace}.
*/
registerAdapter(providers: string[], adapter: LlmAdapter): () => void
registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle
/**
* Describe provider routes with a registered adapter.
@@ -880,9 +880,9 @@ async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise<Prepared
stream(options: GenerateOptions): AsyncIterable<StreamChunk>
```
Types: [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmCallConfig](../core-data-structures/core.md) · [LlmConfigurableProvider](../core-data-structures/core.md) · [LlmModelInfo](../core-data-structures/core.md) · [LlmProviderInfo](../core-data-structures/core.md) · [LlmResolvedModelInfo](../core-data-structures/core.md) · [PreparedLlmCall](../core-data-structures/llm-streaming.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md)
Types: [AdapterRegistrationHandle](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmCallConfig](../core-data-structures/core.md) · [LlmConfigurableProvider](../core-data-structures/core.md) · [LlmModelInfo](../core-data-structures/core.md) · [LlmProviderInfo](../core-data-structures/core.md) · [LlmResolvedModelInfo](../core-data-structures/core.md) · [PreparedLlmCall](../core-data-structures/llm-streaming.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md)
Source: [`packages/llm/llm/src/index.ts:203`](../../packages/llm/llm/src/index.ts)
Source: [`packages/llm/llm/src/index.ts:223`](../../packages/llm/llm/src/index.ts)
## `ctx.permission` — `PermissionService`

View File

@@ -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/core.md
core.md: 8a052f30b340e3f352b2b186fe646629e215b8c0
core.zh.md: 2c808448138f08c8bbf4bc63c60ee69e6537d5b1
core.md: 6e52ca14634ea622444b5df7ea2780912ac03d24
core.zh.md: cbe2ba24a80fa22873697a40c3a7a9b4b71dd3e7

View File

@@ -183,6 +183,30 @@ Source: [`packages/llm/llm/src/types.ts`](../../packages/llm/llm/src/types.ts)
Provider and model discovery uses small provider-neutral descriptors. A model catalog is advisory: routing still keys on a registered provider, and an adapter may accept unlisted model ids.
Registering an adapter returns a handle: the disposer, plus the atomic route replacement a plugin whose route set is user-configurable needs.
```ts type-equiv
/**
* What {@link LlmService.registerAdapter} returns: the disposer, plus an
* atomic route replacement for the same adapter instance.
*/
interface AdapterRegistrationHandle {
/** Release every route this registration currently holds. */
(): void
/**
* Replace this registration's routes with `providers`, keeping the same
* adapter instance. The candidate set is validated in full first — a
* conflict with another adapter, an invalid name, or bad provider metadata
* throws and leaves the current routes untouched — and the swap itself is
* one synchronous section, so no request can observe a gap. An empty array
* is legal here (a settings section that emptied holds zero routes while
* staying registered), unlike an empty initial registration.
* @param providers - the complete next route set for this registration.
*/
replace(providers: string[]): void
}
```
```ts type-equiv
/** Display metadata for one registered provider route. */
interface LlmProviderInfo {

View File

@@ -189,6 +189,30 @@ interface MessageSourceMap {
提供方与模型发现使用小型、提供方无关的描述符。模型目录仅供参考:路由仍以已注册提供方为键,适配器也可以接受未列出的模型 id。
注册适配器会返回一个句柄:既是释放器,也带有原子的路由替换——路由集合由用户配置决定的插件正需要它。
```ts type-equiv
/**
* What {@link LlmService.registerAdapter} returns: the disposer, plus an
* atomic route replacement for the same adapter instance.
*/
interface AdapterRegistrationHandle {
/** Release every route this registration currently holds. */
(): void
/**
* Replace this registration's routes with `providers`, keeping the same
* adapter instance. The candidate set is validated in full first — a
* conflict with another adapter, an invalid name, or bad provider metadata
* throws and leaves the current routes untouched — and the swap itself is
* one synchronous section, so no request can observe a gap. An empty array
* is legal here (a settings section that emptied holds zero routes while
* staying registered), unlike an empty initial registration.
* @param providers - the complete next route set for this registration.
*/
replace(providers: string[]): void
}
```
```ts type-equiv
/** Display metadata for one registered provider route. */
interface LlmProviderInfo {

View File

@@ -26,7 +26,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:406`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` |
| `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:154`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy`, [`tui`](../packages/ui/tui) |
| `credentials/updated` | `emit` | [`packages/credentials/credentials/src/index.ts:62`](../packages/credentials/credentials/src/index.ts) | [`credentials-local`](../packages/credentials/credentials-local) (`emit`) | `apiproxy`, [`credentials`](../packages/credentials/credentials) |
| `credentials/updated` | `emit` | [`packages/credentials/credentials/src/index.ts:67`](../packages/credentials/credentials/src/index.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | `apiproxy`, [`credentials`](../packages/credentials/credentials) |
| `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) |
| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy), [`skill-local`](../packages/skill/skill-local) |

View File

@@ -486,6 +486,7 @@ flowchart TD
pkg_sandbox_local --> pkg_llm
pkg_sandbox_local --> pkg_sandbox
pkg_sandbox_policy --> pkg_invariants
pkg_sandbox_policy --> pkg_paths
pkg_sandbox_policy --> pkg_sandbox
pkg_sandbox_policy --> pkg_session
pkg_session_projection --> pkg_invariants
@@ -1104,7 +1105,7 @@ flowchart TD
| [`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) |
| [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) |
| [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) |
| [`session-projection`](../packages/session-projection/session-projection) | `session-projection` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
| [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection) |

View File

@@ -191,10 +191,13 @@ describe('headless stream-json snapshots', () => {
prepare: (cwd) => { runCwd = cwd },
})
// The guidance leads with the credential store — the path that keeps the
// secret out of configuration files — and offers a literal key last.
expect(result.stderr).toBe(
'dsh-cli-demo: turn 1 failed at step 1: llm-deepseek: no API key for provider route "deepseek-official";'
+ ' set the llm-deepseek "apiKey" setting, store DEEPSEEK_API_KEY with the credentials service,'
+ ' or export DEEPSEEK_API_KEY\n',
+ ' 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\n',
)
const normalized = normalizeHeadlessStream(result.stdout, runCwd)
if (refreshing) await writeFile(streamExpected, normalized)

View File

@@ -4,5 +4,5 @@
{"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":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","reasoningEffort":"high"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":5,"time":0,"data":{"turn":1,"step":1}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":6,"time":0,"data":{"turn":1,"reason":{"kind":"error","step":1,"failure":{"message":"llm-deepseek: no API key for provider route \"deepseek-official\"; set the llm-deepseek \"apiKey\" setting, store DEEPSEEK_API_KEY with the credentials service, or export DEEPSEEK_API_KEY","code":"MISSING_CREDENTIAL"}}}}}
{"type":"result","success":false,"sessionId":"{{sessionId}}","turn":1,"result":"","reason":{"kind":"error","step":1,"failure":{"message":"llm-deepseek: no API key for provider route \"deepseek-official\"; set the llm-deepseek \"apiKey\" setting, store DEEPSEEK_API_KEY with the credentials service, or export DEEPSEEK_API_KEY","code":"MISSING_CREDENTIAL"}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":6,"time":0,"data":{"turn":1,"reason":{"kind":"error","step":1,"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":"result","success":false,"sessionId":"{{sessionId}}","turn":1,"result":"","reason":{"kind":"error","step":1,"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"}}}

View File

@@ -355,18 +355,23 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
expect(output).toContain('\u001B[?2004l')
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
it('applies the personal overlay: config.yaml patches the tree and .env feeds its !!js', async () => {
// The whole personal-config chain in one boot: the personal .env supplies
// the variable, config.yaml patches the tui-agent entry with a `!!js`
// reference to it, and the banner renders the patched welcome verbatim.
it('applies the personal overlay: config.yaml patches the tree, the invoking directory\'s .env feeds its !!js, and the home .env stays out of the environment', async () => {
// The whole personal-config chain in one boot, plus the environment layer
// it deliberately excludes. The single `!!js` expression prefers the
// personal variable, so the patched welcome can only render the project
// value when the harness home's .env — the credential store of
// `dsh-credentials-local` — is NOT hoisted into `process.env`; hoisting it
// would make every stored key read as a read-only launch override on the
// next run and hand it to every subprocess the agent starts.
const output = await smoke({
label: 'dsh personal overlay',
tempDirPrefix: 'dsh-personal-overlay-',
binScript: dshBinScript,
configArgs: [],
prepare: seedWorkspace({
workspace: { '.env': 'DSH_PROJECT_WELCOME=PROJECT OVERLAY READY.\n' },
personal: {
'.env': 'DSH_PERSONAL_WELCOME=PERSONAL OVERLAY READY.\n',
'.env': 'DSH_PERSONAL_WELCOME=HOME ENV LEAKED.\n',
'config.yaml': [
'- id: tui-agent',
" name: '@deepseek-ai/dsh-tui-demo'",
@@ -374,14 +379,15 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
' provider: deepseek-official',
' model: deepseek-v4-flash',
' workspaceContext: false',
' welcome: !!js process.env.DSH_PERSONAL_WELCOME',
' welcome: !!js process.env.DSH_PERSONAL_WELCOME ?? process.env.DSH_PROJECT_WELCOME',
'',
].join('\n'),
},
}),
actions: [{ waitFor: 'PERSONAL OVERLAY READY.', send: '/exit\r' }],
actions: [{ waitFor: 'PROJECT OVERLAY READY.', send: '/exit\r' }],
})
expect(output).toContain('PERSONAL OVERLAY READY.')
expect(output).toContain('PROJECT OVERLAY READY.')
expect(output).not.toContain('HOME ENV LEAKED.')
expect(output).toContain('\u001B[?2004l')
}, LOADER_SMOKE_TEST_TIMEOUT_MS)

View File

@@ -405,8 +405,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
summary: 'The abstract `llm` service: an adapter registry plus a streaming model-call surface, interceptable via the `llm/stream` waterfall.',
methods: [
{
signature: 'registerAdapter(providers: string[], adapter: LlmAdapter): () => void',
jsDoc: '/**\n * Register an adapter for the given provider routes. Throws `LlmError` with code\n * `DUPLICATE_ADAPTER` if any provider already has an adapter (all-or-nothing).\n * Disposed with the fiber.\n * @param providers - every provider route this adapter should serve.\n * @param adapter - the adapter that streams calls for those providers.\n * @returns the disposer that unregisters all of them.\n */',
signature: 'registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle',
jsDoc: '/**\n * Register an adapter for the given provider routes. Throws `LlmError` with code\n * `DUPLICATE_ADAPTER` if any provider already has an adapter (all-or-nothing).\n * Disposed with the fiber.\n * @param providers - every provider route this adapter should serve.\n * @param adapter - the adapter that streams calls for those providers.\n * @returns the disposer, carrying {@link AdapterRegistrationHandle.replace}.\n */',
},
{
signature: 'listProviders(): LlmProviderInfo[]',
@@ -1305,7 +1305,7 @@ export const EVENT_API: readonly EventApiEntry[] = [
name: 'credentials/updated',
mode: 'emit',
signature: '\'credentials/updated\'(ref: CredentialRef): void',
jsDoc: '/**\n * Committed change to a provider-managed credential source: a `set`, an\n * `unset`, or an external edit observed in storage. Ambient\n * process-environment changes are not observable and never emit.\n * @param ref - the reference whose stored value changed.\n * @mode emit\n */',
jsDoc: '/**\n * Committed change to a provider-managed credential source: a `set`, an\n * `unset`, or an external edit observed in storage. Ambient\n * process-environment changes are not observable and never emit. Listener\n * failures are contained and logged — a sync throw and an async rejection\n * alike — without changing the committed operation\'s outcome, except\n * `INVARIANT`-coded failures, which rethrow after every listener ran;\n * that rethrow reaches the emitter only from synchronous listeners, so\n * invariant checks on this event must not be async functions.\n * @param ref - the reference whose stored value changed.\n * @mode emit\n */',
summary: 'Committed change to a provider-managed credential source: a `set`, an `unset`, or an external edit observed in storage.',
},
{
@@ -1536,6 +1536,10 @@ export const EVENT_API: readonly EventApiEntry[] = [
/** Shapes of every exported type the SERVICE_API signatures reference (transitively), sorted by name. */
export const TYPE_API: readonly TypeApiEntry[] = [
{
name: 'AdapterRegistrationHandle',
declaration: 'export interface AdapterRegistrationHandle {\n (): void;\n replace(providers: string[]): void;\n}',
},
{
name: 'Agent',
declaration: 'export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise<void>;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n}',

View File

@@ -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: 277c7db02836819e34a5c2db8aaf542c8eeec162
README.zh.md: af1b840142d214b8cd8cf59690cb5773fae2e867
README.md: 126140b10719dc6f7bc458a118ba1feb1f440270
README.zh.md: c22575115ab44b5e86a847ffe8f1fa1a795b580d

View File

@@ -22,7 +22,7 @@ The environment wins because a launch-time override (`DEEPSEEK_API_KEY=… dsh`,
## The document
dotenv format, parsed with `dotenv` and edited by a line editor that preserves every byte it does not own: `set` rewrites the first assignment of its key in place (dropping later duplicates, which dotenv's last-wins reading would otherwise let override the edit), `unset` removes only the owning line, comments and unrelated lines survive verbatim. Writes go through [`dsh-atomic-write`](../../util/atomic-write/README.md) with mode `0600`.
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.
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.
@@ -30,6 +30,12 @@ Values are rendered in the narrowest style dotenv reads back verbatim — bare,
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.
## 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, so under the shipped `danger-full-access` default they can read this file exactly like any other file the user owns, and 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 Personal config](../../ui/app-boot/README.md#personal-config)), 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.
## Model Experience
Indirectly, through the consuming LLM adapters: stored values authorize their provider requests, and the adapter owns every model-visible surface.
@@ -40,7 +46,9 @@ 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; edit the file directly.
- **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): only a confining sandbox mode denies it, 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.
- **Atomic, not crash-durable** — inherited from `dsh-atomic-write`; the store re-reads on boot.

View File

@@ -22,7 +22,7 @@
## 文档本身
dotenv 格式,用 `dotenv` 解析;写回用行级编辑器,保留一切不属于本次编辑的字节:`set` 原位改写该键的第一条赋值行丢弃后续重复行——dotenv 按最后一条生效,重复行会反过来覆盖这次编辑),`unset` 只删除所属行,注释无关行逐字保留。落盘经 [`dsh-atomic-write`](../../util/atomic-write/README.md),权限 `0600`
dotenv 格式,用 `dotenv` 解析;写回用物理行级编辑器,保留一切不属于本次编辑的字节:`set` 原位改写该键的第一条赋值行、沿用该行自身的行尾丢弃后续重复行——dotenv 按最后一条生效,重复行会反过来覆盖这次编辑),`unset` 只删除所属行,注释无关行、CRLF 行尾,以及另一个键的引号多行值的续行,都逐字保留。每次写入都先在 [`dsh-atomic-write`](../../util/atomic-write/README.md) 的跨进程写锁下重读文档、把此前未观察到的一切发布出去,再在仅属主可访问(`0700`)的目录下以 `0600` 权限原子提交——因此并发写入者、或落在 watcher 防抖窗口内的外部编辑会被并入,而不是被覆盖
值按 dotenv 能逐字读回的最窄样式渲染——裸值其次单引号完全字面再次双引号仅限无反斜杠双引号读取会展开转义。任何样式都无法表示的值以及已经跨越多个物理行的条目都会响亮失败而不是被静默破坏。空的存储值等于不存在seam 规则)。
@@ -30,6 +30,12 @@ dotenv 格式,用 `dotenv` 解析;写回用行级编辑器,保留一切不
外部编辑在快照**整体替换**后按变更引用逐个发布 `credentials/updated`——磁盘上删掉的条目绝不在内存滞留。provider 自己的写入按内容识别,只发布属于该次提交的一个事件。运行期文档不可读时保留最后可用快照并告警;文件不存在即空存储;启动时不可读则响亮失败。非 POSIX 标识符的键属于被保留的文件内容seam 无法寻址。
## 安全边界
文档在 `0700` 目录下以 `0600` 权限存放,这挡得住其他 OS 用户,**挡不住**模型。工具进程bash、文件系统工具以同一用户身份运行因此在出厂默认的 `danger-full-access`它们读这个文件与读该用户拥有的任何其他文件毫无二致也没有任何沙箱模式会把它单独挑出来。harness 真正守住的更窄:它绝不把该文档的解析后路径交给模型,也绝不把它载入进程环境(见 [app-boot 的个人配置](../../ui/app-boot/README.md#personal-config)),因此要拿到这个值,需要刻意去读一条并未交给 agent 的路径。
这是审慎,不是边界。必须让提供方密钥远离自身 agent 的部署无法靠文件权限做到OS 钥匙串 provider——一个模型的进程根本读不到的存储——才是延后的答案它应当作为平级包与本 provider 并列。
## Model Experience
经由消费它的 LLM 适配器间接生效:存储的值为适配器的提供方请求授权,每个模型可见面都归适配器所有。
@@ -40,7 +46,9 @@ dotenv 格式,用 `dotenv` 解析;写回用行级编辑器,保留一切不
## Known Limitations and Deferred Work
- **多行条目拒绝 `set`/`unset`**——行编辑器不改写会被它破坏的条目;请直接编辑文件。
- **多行条目拒绝 `set`/`unset`**——行编辑器不改写会被它破坏的条目;`describe` 把它们报为 `writable: false`,编辑必须直接落到文件
- **同一引用的并发写入是后写胜出**——写锁加读-改-写让并发写入者不会丢掉彼此的条目,但两个写入者编辑同一个引用时仍以较后的写入为准;没有修订检查。
- **同 UID 进程可以读取该文档**——见[安全边界](#security-boundary)只有受限沙箱模式会拒绝它OS 钥匙串 provider 仍是延后项。
- **无法表示的值响亮失败**——控制字符,或同时混用两种引号又含反斜杠的值,无法在 dotenv 行格式中往返。
- **环境变化不可见**——每次解析实时读取 `process.env`,但那里的变化不可能发出事件。
- **原子但不保证崩溃持久**——继承自 `dsh-atomic-write`;存储在启动时重新读取。

View File

@@ -3,19 +3,21 @@
* 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:
* `set`/`unset` rewrite only their own line and preserve every other byte,
* external edits hot-publish through the seam, and each reload replaces the
* snapshot wholesale so a deleted entry never lingers in memory.
* 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.
* @module @deepseek-ai/dsh-credentials-local
*/
import { Context, Service } from 'cordis'
import z from 'schemastery'
import { watch as chokidarWatch } from 'chokidar'
import { readFile } from 'node:fs/promises'
import { join, resolve } from 'node:path'
import { mkdir, readFile } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { parse } from 'dotenv'
import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
import { Credentials, credentialRef } from '@deepseek-ai/dsh-credentials'
import type { CredentialInfo, CredentialRef, ResolvedCredential } from '@deepseek-ai/dsh-credentials'
@@ -58,11 +60,6 @@ function isENOENT(error: unknown): boolean {
return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT'
}
/** Match the physical line(s) assigning one reference (ref chars need no escaping). */
function refLinePattern(ref: CredentialRef): RegExp {
return new RegExp(`^\\s*(?:export\\s+)?${ref}\\s*=`)
}
/** Values that survive a dotenv round-trip without quoting. */
const BARE_VALUE = /^[A-Za-z0-9_@%+:,./-]+$/
@@ -90,30 +87,99 @@ function renderLine(ref: CredentialRef, value: string): string {
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 = ['\'', '"', '`']
/**
* Replace, insert, or delete one reference's assignment while preserving every
* other byte. The first matching line is rewritten in place; further matches
* are dropped (dotenv reads the last one, so duplicates are dead weight that
* would otherwise override the edit).
* 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 upsertLine(text: string | undefined, ref: CredentialRef, line: string | undefined): string {
const lines = text === undefined || text.length === 0 ? [] : text.split('\n')
if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
const matcher = refLinePattern(ref)
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)
}
/**
* 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.
*/
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
for (const current of lines) {
if (matcher.test(current)) {
if (line !== undefined && !placed) {
out.push(line)
placed = true
}
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
}
out.push(current)
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 (line !== undefined && !placed) out.push(line)
return out.length === 0 ? '' : `${out.join('\n')}\n`
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('')
}
/** File-backed credentials provider (`$DSH_HOME/.env`). */
@@ -137,10 +203,12 @@ export class CredentialsLocal extends Credentials {
private text: string | undefined
/** Parsed document snapshot; replaced wholesale on every reload. */
private values = new Map<string, string>()
/** Serializes watcher-triggered reloads so reads never interleave. */
private refreshTask: Promise<void> = Promise.resolve()
/** Serializes writes to the one document; settled tail. */
private writeChain: Promise<unknown> = Promise.resolve()
/**
* Single exclusive operation chain: watcher reloads and line edits run one
* at a time in queue order (settled tail), so an edit can never render from
* text a concurrent reload is busy replacing.
*/
private operations: Promise<void> = Promise.resolve()
/** Set at dispose: refuse new writes and let in-flight work no-op. */
private closed = false
@@ -159,10 +227,10 @@ export class CredentialsLocal extends Credentials {
async* [Service.init](): AsyncGenerator<() => Promise<void> | void, void, void> {
yield async () => {
// Drain: refuse new writes, then settle the queued ones so disposal
// Drain: refuse new operations, then settle the queued ones so disposal
// completes only once storage is quiescent.
this.closed = true
await this.writeChain
await this.operations
}
await this.loadInitial()
if (!this.spec.watch) return
@@ -178,26 +246,27 @@ export class CredentialsLocal extends Credentials {
})
watcher.on('all', () => {
if (this.closed) return
this.refreshTask = this.refreshTask.then(() => this.refresh()).catch((error: unknown) => {
// Only an invariant violation escaping the update fan-out can reject a
// refresh; keep the reload queue alive and surface it as an error so
// one poisoned commit cannot silently end hot reloading forever.
this.ctx.logger.error('credentials-local: reload commit failed at %s', this.spec.filename)
this.ctx.logger.error(error)
})
this.queueRefresh()
})
watcher.on('ready', () => {
// The initial load raced the watcher's own setup: a change written
// between that read and the watcher becoming active never fires an
// event. One reconcile at ready closes the gap.
if (this.closed) return
this.queueRefresh()
})
watcher.on('error', (error) => {
this.ctx.logger.warn('credentials-local: watcher error on %s', this.spec.filename)
this.ctx.logger.warn(error)
})
/* jscpd:ignore-end */
yield async () => {
// Quiesce: stop accepting events, close the watcher, then wait out any
// queued or in-flight refresh so nothing publishes after disposal.
// queued or in-flight operation so nothing publishes after disposal.
this.closed = true
await watcher.close()
await this.refreshTask
await this.operations
}
/* jscpd:ignore-end */
}
override resolve(ref: CredentialRef): Promise<ResolvedCredential | undefined> {
@@ -215,7 +284,9 @@ export class CredentialsLocal extends Credentials {
}
const stored = this.values.get(ref)
if (stored !== undefined && stored.length > 0) {
return Promise.resolve({ configured: true, source: 'file', writable: true })
// 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') })
}
return Promise.resolve({ configured: false, writable: true })
}
@@ -231,6 +302,30 @@ export class CredentialsLocal extends Credentials {
await this.write(ref, undefined)
}
/* jscpd:ignore-start -- the operation-chain and reload lifecycle is the same
reviewed contract as settings-local, deliberately mirrored (prefer symmetry
for parallel values); the two providers own different documents and
failure policies, so extracting the shape would couple their teardown
semantics across packages for a handful of lines. */
/** Queue one exclusive document operation behind every earlier one. */
private enqueue<T>(operation: () => Promise<T>): Promise<T> {
const task = this.operations.then(operation)
this.operations = task.then(() => undefined, () => undefined)
return task
}
/** Queue a reload; only an invariant violation escaping the fan-out can reject it. */
private queueRefresh(): void {
void this.enqueue(() => this.refresh()).catch((error: unknown) => {
// Only an invariant violation escaping the update fan-out can reject a
// refresh; keep the operation queue alive and surface it as an error so
// one poisoned commit cannot silently end hot reloading forever.
this.ctx.logger.error('credentials-local: reload commit failed at %s', this.spec.filename)
this.ctx.logger.error(error)
})
}
/* jscpd:ignore-end */
/** Queue one line edit; entry checks reject early, the queue re-judges them at run time. */
private async write(ref: CredentialRef, value: string | undefined): Promise<void> {
const verb = value === undefined ? 'unset' : 'set'
@@ -238,32 +333,43 @@ export class CredentialsLocal extends Credentials {
throw new Error(`credentials-local is disposed: cannot ${verb} "${ref}"`)
}
this.assertUnshadowed(ref, verb)
// The stored tail is settled on both outcomes, so chaining needs no catch
// and one rejected write can never poison the queue for later callers.
const previous = this.writeChain
const run = previous.then(async () => {
return this.enqueue(async () => {
if (this.isClosed()) {
throw new Error(`credentials-local was disposed before the queued "${ref}" ${verb} ran`)
}
// Re-judged at run time: the environment may have changed while queued.
this.assertUnshadowed(ref, verb)
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))
// 0600: a document holding secrets is never world-readable.
await writeFileAtomic(this.spec.filename, nextText, { mode: 0o600 })
this.text = nextText
if (value === undefined) this.values.delete(ref)
else this.values.set(ref, value)
this.ctx.emit('credentials/updated', ref)
// The writer lock's exclusive create needs the parent to exist; 0700
// because the harness home holds user-private data.
await mkdir(dirname(this.spec.filename), { recursive: true, mode: 0o700 })
await withFileLock(this.spec.filename, async () => {
// Read-modify-write: fold in any on-disk state this process has not
// observed yet — an external edit still inside the watcher debounce
// window, a change the watcher missed, or another process's write —
// so the line edit below can never resurrect a stale document.
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))
// 0600: a document holding secrets is never world-readable.
await writeFileAtomic(this.spec.filename, nextText, { mode: 0o600, dirMode: 0o700 })
this.text = nextText
if (value === undefined) this.values.delete(ref)
else this.values.set(ref, value)
// After the commit: a broken observer must never make the durable
// write look failed (an INVARIANT failure still rethrows).
this.notifyUpdated(ref)
}, {
onStaleBreak: (lockPath) => {
this.ctx.logger.warn('credentials-local: breaking a stale writer lock at %s', lockPath)
},
})
})
this.writeChain = run.then(() => undefined, () => undefined)
return run
}
/** Reject a write the live environment would shadow into apparent no-effect. */
@@ -290,23 +396,40 @@ export class CredentialsLocal extends Credentials {
this.values = new Map(Object.entries(parse(text)))
}
/* jscpd:ignore-start -- same deliberate mirror of settings-local's reload and
reconcile policy: warn-and-keep on a reload, throw on a write, invariant
failures propagate. */
/**
* Re-read the document after a watcher event. Unchanged content (including
* this provider's own writes) is a no-op; an unreadable document keeps the
* last good snapshot and warns — a live hot-reload must never take the
* process down. dotenv parsing is lenient by design and cannot fail.
* process down. An invariant violation escaping the fan-out is not a reload
* failure and propagates to the queue's error surface.
*/
private async refresh(): Promise<void> {
if (this.closed) return
try {
await this.reconcileFromDisk()
} catch (error) {
if ((error as { code?: unknown } | null)?.code === 'INVARIANT') throw error
this.ctx.logger.warn('credentials-local: reload failed at %s; keeping the last good document', this.spec.filename)
this.ctx.logger.warn(error)
}
}
/**
* 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.
*/
private async reconcileFromDisk(): Promise<void> {
let text: string | undefined
try {
text = await readFile(this.spec.filename, 'utf8')
} catch (error) {
if (!isENOENT(error)) {
this.ctx.logger.warn('credentials-local: reload failed at %s; keeping the last good document', this.spec.filename)
this.ctx.logger.warn(error)
return
}
if (!isENOENT(error)) throw error
text = undefined
}
if (text === this.text || this.isClosed()) return
@@ -314,8 +437,9 @@ export class CredentialsLocal extends Credentials {
const changed = this.changedRefs(this.values, next)
this.text = text
this.values = next
for (const ref of changed) this.ctx.emit('credentials/updated', ref)
for (const ref of changed) this.notifyUpdated(ref)
}
/* jscpd:ignore-end */
/** Seam-addressable entries whose effective (non-empty) value changed. */
private changedRefs(prev: Map<string, string>, next: Map<string, string>): CredentialRef[] {

View File

@@ -6,11 +6,15 @@ import { join } from 'node:path'
import { credentialRef } from '@deepseek-ai/dsh-credentials'
import { CredentialsLocal } from '../src/index.ts'
// The atomic write is the only asynchronous hold point inside a queued write;
// gating it makes the dispose-versus-queued-write race fully deterministic.
vi.mock('@deepseek-ai/dsh-atomic-write', () => {
// The atomic write is the gated asynchronous hold point inside a queued
// write; gating it makes the dispose-versus-queued-write race fully
// deterministic. The lock helper passes through so the gated operation still
// runs inside its real acquire/release cycle.
vi.mock('@deepseek-ai/dsh-atomic-write', async (importOriginal) => {
const actual = await importOriginal<typeof import('@deepseek-ai/dsh-atomic-write')>()
let gate: Promise<void> = Promise.resolve()
return {
...actual,
writeFileAtomic: vi.fn(() => gate),
__setGate: (next: Promise<void>) => {
gate = next

View File

@@ -0,0 +1,202 @@
// 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.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { mkdtemp, readFile, rm, stat, utimes, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { credentialRef } from '@deepseek-ai/dsh-credentials'
import { CredentialsLocal } from '../src/index.ts'
const ALPHA = credentialRef('DSH_REVIEW_ALPHA')
const BETA = credentialRef('DSH_REVIEW_BETA')
const INNER = credentialRef('DSH_REVIEW_INNER')
const cleanups: Array<() => Promise<void>> = []
afterEach(async () => {
while (cleanups.length > 0) await cleanups.pop()!()
})
async function tempDir(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'dsh-cred-review-'))
cleanups.push(() => rm(dir, { recursive: true, force: true }))
return dir
}
async function boot(config: ConstructorParameters<typeof CredentialsLocal>[1]): Promise<Context> {
const ctx = new Context()
const fiber = ctx.plugin(CredentialsLocal, config)
cleanups.push(async () => { await fiber.dispose() })
await fiber
return ctx
}
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 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 ctx.credentials.set(ALPHA, 'two')
const text = await readFile(path, 'utf8')
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' })
})
it('keeps both refs when two providers write the same document concurrently', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
const first = await boot({ path, watch: false })
const second = await boot({ path, watch: false })
await Promise.all([
(async () => { for (const value of ['1', '2', '3'] as const) await first.credentials.set(ALPHA, value) })(),
(async () => { for (const value of ['1', '2', '3'] as const) await second.credentials.set(BETA, value) })(),
])
const third = await boot({ path, watch: false })
expect(await third.credentials.resolve(ALPHA)).toEqual({ value: '3', source: 'file' })
expect(await third.credentials.resolve(BETA)).toEqual({ value: '3', source: 'file' })
})
it('breaks a stale writer lock with a warning and writes through', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
const ctx = await boot({ path, watch: false })
await writeFile(`${path}.lock`, 'crashed-holder\n')
const past = (Date.now() - 60_000) / 1000
await utimes(`${path}.lock`, past, past)
await ctx.credentials.set(ALPHA, 'nine')
expect(await readFile(path, 'utf8')).toContain(`${ALPHA}=nine`)
})
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 })
await ctx.credentials.set(ALPHA, 'one')
expect((await stat(home)).mode & 0o777).toBe(0o700)
})
})
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 })
ctx.on('credentials/updated', () => {
throw new Error('observer boom')
})
const second = vi.fn()
ctx.on('credentials/updated', second)
await expect(ctx.credentials.set(ALPHA, 'one')).resolves.toBeUndefined()
expect(second).toHaveBeenCalledWith(ALPHA)
expect(await ctx.credentials.resolve(ALPHA)).toEqual({ value: 'one', source: 'file' })
})
it('contains an async listener rejection', async () => {
const dir = await tempDir()
const ctx = await boot({ path: join(dir, '.env'), 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'))
ctx.on('credentials/updated', boom)
await expect(ctx.credentials.set(ALPHA, 'one')).resolves.toBeUndefined()
await new Promise(resolve => setTimeout(resolve, 10))
})
it('rethrows an invariant-coded failure after the commit and the remaining listeners', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
const ctx = await boot({ path, watch: false })
ctx.on('credentials/updated', () => {
throw Object.assign(new Error('forged relation'), { code: 'INVARIANT' })
})
const second = vi.fn()
ctx.on('credentials/updated', second)
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 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 () => {
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 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' })
})
it('preserves CRLF line endings on untouched and edited lines', 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 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' })
})
})

View File

@@ -151,6 +151,7 @@ describe('watcher pipeline', () => {
await fiber.dispose()
disposed = true
instance!.watcher.emit('all', 'change', path)
instance!.watcher.emit('ready')
await new Promise(resolve => setTimeout(resolve, 100))
expect(postDisposeCommits).toBe(0)
})
@@ -204,4 +205,19 @@ describe('watcher pipeline', () => {
await new Promise(resolve => setTimeout(resolve, 50))
expect(await ctx.credentials.resolve(KEY)).toBeUndefined()
})
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 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`)
const [instance] = await fakeInstances()
instance!.watcher.emit('ready')
await vi.waitFor(async () => {
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'written-before-ready', source: 'file' })
})
})
})

View File

@@ -55,7 +55,12 @@ declare module 'cordis' {
/**
* Committed change to a provider-managed credential source: a `set`, an
* `unset`, or an external edit observed in storage. Ambient
* process-environment changes are not observable and never emit.
* process-environment changes are not observable and never emit. Listener
* failures are contained and logged — a sync throw and an async rejection
* alike — without changing the committed operation's outcome, except
* `INVARIANT`-coded failures, which rethrow after every listener ran;
* that rethrow reaches the emitter only from synchronous listeners, so
* invariant checks on this event must not be async functions.
* @param ref - the reference whose stored value changed.
* @mode emit
*/
@@ -109,6 +114,49 @@ export abstract class Credentials extends Service {
* @param ref - the reference to remove.
*/
abstract unset(ref: CredentialRef): Promise<void>
/* jscpd:ignore-start -- deliberate symmetry with the settings seam's commit
fan-out: the contained-dispatch shape is the reviewed listener-lifecycle
contract, and extracting it would couple the two seams' event semantics. */
/**
* Fan `credentials/updated` out with contained listener failures: every
* listener runs, and a sync throw or async rejection is logged without
* changing the committed operation's outcome — except `INVARIANT`-coded
* failures, which rethrow after every listener ran (the rethrow reaches the
* caller only from synchronous listeners, so invariant checks on this event
* must not be async functions). Providers call this only after the write or
* reload actually committed, so a broken observer can never make a durable
* change look failed.
* @param ref - the reference whose stored value changed.
*/
protected notifyUpdated(ref: CredentialRef): void {
let invariantFailure: unknown
const args = ['credentials/updated', ref]
for (const listener of this.ctx.events.dispatch('emit', args) as Array<(...listenerArgs: unknown[]) => unknown>) {
try {
const returned = listener(ref)
if (returned != null && typeof (returned as PromiseLike<unknown>).then === 'function') {
void Promise.resolve(returned as PromiseLike<unknown>).then(undefined, (error: unknown) => {
this.warnListenerFailure(ref, error)
})
}
} catch (error) {
if ((error as { code?: unknown } | null)?.code === 'INVARIANT') {
invariantFailure ??= error
continue
}
this.warnListenerFailure(ref, error)
}
}
if (invariantFailure !== undefined) throw invariantFailure as Error
}
/* jscpd:ignore-end */
/** Contained-listener diagnostic shared by the sync and async failure paths. */
private warnListenerFailure(ref: CredentialRef, error: unknown): void {
this.ctx.logger.warn('credentials: a credentials/updated listener for "%s" failed', ref)
this.ctx.logger.warn(error)
}
}
export default Credentials

View File

@@ -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: 186739a3b0ee423afac27ef43c41f42a0e07ee84
README.zh.md: 844fd1422339ad88640a948a735ac1cf9f130ff0
README.md: 11bed74b4952624208f23f093b787eb978cfef69
README.zh.md: 5fa75ad3434fe9610ba8005d436af51fd7a134f9

View File

@@ -50,7 +50,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: a 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. 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: a 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. 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. 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.

View File

@@ -50,7 +50,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 时——读取原始环境变量。任何地方都没有密钥的请求以 `MISSING_CREDENTIAL` 失败并点名每个配置入口同时路由保持注册、catalog 保持可浏览——首次运行的上手流程就是「浏览模型、存入密钥、再次发起提示」,中间无需任何重启。
- **`ctx.credentials`**——API 密钥按每次 stream 调用解析,取自与端点*同一*份解析后的快照:非空的字面 `apiKey` 优先,其次经凭据 seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`),最后——仅在未挂载 seam 时——读取原始环境变量。由于凭据事实与连接事实同行,被 resolver 拒绝的 settings 快照既不贡献自己的端点,也不贡献自己的密钥:整个先前世代继续服务。任何地方都没有密钥的请求以 `MISSING_CREDENTIAL` 失败并点名每个配置入口同时路由保持注册、catalog 保持可浏览——首次运行的上手流程就是「浏览模型、存入密钥、再次发起提示」,中间无需任何重启。
唯一在注册期捕获的事实是重试策略:其解析值变化时,插件原地重新注册该路由(同一适配器实例、一个同步区段),因此 `ctx.llm.providerRetryPolicy('deepseek-official')` 始终报告当前策略。

View File

@@ -17,6 +17,7 @@ import type {
ResolvedRetryPolicy,
StreamChunk,
} from '@deepseek-ai/dsh-llm'
import type { CredentialRef } from '@deepseek-ai/dsh-credentials'
import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout'
import { serializeRequest } from './serialize.ts'
import type { RequestDefaults } from './serialize.ts'
@@ -45,6 +46,14 @@ export interface DeepSeekCatalogModel {
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.
*/
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
/** Positive context capacity used when the selected model has no exact value. */
@@ -62,11 +71,12 @@ export interface DeepSeekAdapterOptions {
/** Current validated connection facts; called once per operation. */
options: () => DeepSeekConnectionOptions
/**
* Resolve the bearer token for one request; called once per stream call and
* frozen for that call. Throws `LlmError` `MISSING_CREDENTIAL` when no key
* is available anywhere.
* Resolve the bearer token for the connection facts of one request. The
* snapshot is passed in — never re-read — so the key can only ever come
* from the same resolution as the endpoint it is sent to. Throws `LlmError`
* `MISSING_CREDENTIAL` when no key is available anywhere.
*/
resolveApiKey: () => Promise<string>
resolveApiKey: (connection: DeepSeekConnectionOptions) => Promise<string>
}
/** Default maximum idle interval while an adapter stream read is outstanding. */
@@ -189,8 +199,10 @@ export class DeepSeekAdapter extends LlmAdapter {
// One resolution per stream call: connection facts and the credential
// freeze here and hold for this whole request, so an in-flight stream
// never observes a configuration change and the next call re-resolves.
// The key resolves *from this snapshot*, so an endpoint and the secret
// sent to it can never come from different configuration generations.
const connection = this.config.options()
const apiKey = await this.config.resolveApiKey()
const apiKey = await this.config.resolveApiKey(connection)
const consumer = new AbortController()
const upstream = options.signal === undefined
? consumer.signal

View File

@@ -16,7 +16,6 @@ import z from 'schemastery'
import { LlmError, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm'
import type { RetryPolicyConfig } from '@deepseek-ai/dsh-llm'
import { credentialRef } from '@deepseek-ai/dsh-credentials'
import type { CredentialRef } from '@deepseek-ai/dsh-credentials'
import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import { DEFAULT_STREAM_IDLE_TIMEOUT_MS, DeepSeekAdapter } from './adapter.ts'
@@ -32,6 +31,8 @@ export const inject = ['llm']
const NS = settingsNamespace('llm-deepseek')
const DEFAULT_API_KEY_ENV = 'DEEPSEEK_API_KEY'
/** The single provider route this plugin owns. */
const PROVIDER = 'deepseek-official'
const DEFAULT_MODELS: DeepSeekCatalogModel[] = [
{ id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', contextWindow: 256_000 },
@@ -89,11 +90,13 @@ 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'
/** Connection facts plus the plugin-consumed credential reference. */
export interface ResolvedDeepSeekOptions extends DeepSeekConnectionOptions {
/** Reference resolved per request when no literal key is configured. */
apiKeyEnv: CredentialRef
}
/**
* One resolution's complete request facts. Connection and credential facts
* are one value on purpose: a snapshot the resolver rejects keeps the whole
* previous generation, so a request can never pair a stale endpoint with a
* newer key.
*/
export type ResolvedDeepSeekOptions = DeepSeekConnectionOptions
/** Resolve, validate, and detach the advisory model catalog. */
function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): DeepSeekCatalogModel[] {
@@ -147,6 +150,7 @@ export function resolveAdapterOptions(config: Config): ResolvedDeepSeekOptions {
)
}
return {
...config.apiKey !== undefined && config.apiKey.length > 0 ? { apiKey: config.apiKey } : {},
apiKeyEnv: credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV),
baseURL: config.baseURL ?? process.env.DEEPSEEK_BASE_URL ?? PUBLIC_BASE_URL,
defaults: {
@@ -187,10 +191,11 @@ export function apply(ctx: Context, config: Config): void {
}
options()
const resolveApiKey = async (): Promise<string> => {
const raw = current()
if (raw.apiKey !== undefined && raw.apiKey.length > 0) return raw.apiKey
const ref = options().apiKeyEnv
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)
@@ -202,19 +207,20 @@ export function apply(ctx: Context, config: Config): void {
if (ambient !== undefined && ambient.length > 0) return ambient
}
throw new LlmError(
'llm-deepseek: no API key for provider route "deepseek-official"; set the llm-deepseek "apiKey" setting,'
+ ` store ${ref} with the credentials service, or export ${ref}`,
`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',
'MISSING_CREDENTIAL',
)
}
const adapter = new DeepSeekAdapter({ options, resolveApiKey })
ctx.llm.registerConfigurableProviders([
{ provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: NS, settingsPath: [] },
{ provider: PROVIDER, displayName: 'DeepSeek', settingsNs: NS, settingsPath: [] },
])
// Route effects bind to this apply fiber via the stable `ctx` reference,
// even when a swap runs inside the scoped settings callback below.
let disposeRoute = ctx.llm.registerAdapter(['deepseek-official'], adapter)
let disposeRoute = ctx.llm.registerAdapter([PROVIDER], adapter)
let registeredPolicy = options().retryPolicy
const ensureRegistrationFacts = (): void => {
const policy = options().retryPolicy
@@ -223,17 +229,10 @@ export function apply(ctx: Context, config: Config): void {
// fact per-request resolution cannot refresh: swap the registration in one
// synchronous section (same adapter instance, no NO_ADAPTER window).
disposeRoute()
disposeRoute = ctx.llm.registerAdapter(['deepseek-official'], adapter)
disposeRoute = ctx.llm.registerAdapter([PROVIDER], adapter)
registeredPolicy = policy
}
void resolveApiKey().then(() => undefined, () => {
// Expected on a first boot with dynamic sources: the route stays
// registered (the catalog is browsable) and each request fails with the
// actionable MISSING_CREDENTIAL message until a key arrives.
ctx.logger.warn('llm-deepseek: no API key resolved yet for route "deepseek-official"; requests will fail until one is configured')
})
installSettingsSection(ctx, NS, Config, config, {
setSource: (source) => {
current = source

View File

@@ -824,8 +824,31 @@ describe('plugin registration and config', () => {
await expect(ctx.llm.listModels('deepseek-official')).resolves.toHaveLength(2)
await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }))
.rejects.toMatchObject({ 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.
await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }))
.rejects.toThrow(/store DEEPSEEK_API_KEY with the credentials service, or export DEEPSEEK_API_KEY/)
.rejects.toThrow(/store DEEPSEEK_API_KEY through the credentials service.*as a last resort.*"apiKey"/s)
})
it('reads the ambient variable when no credentials seam is mounted', async () => {
// The plain cordis.yml composition: no credential provider, the key in
// the launching environment.
vi.stubEnv('DEEPSEEK_API_KEY', 'ambient-key')
const server = await mockServer([{ kind: 'sse', events: textEvents }])
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmDeepSeek, { baseURL: server.url })
await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
expect(server.headers[0]?.authorization).toBe('Bearer ambient-key')
})
it('treats an empty ambient variable as no key when no credentials seam is mounted', async () => {
vi.stubEnv('DEEPSEEK_API_KEY', '')
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmDeepSeek, { baseURL: 'http://127.0.0.1:1' })
await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }))
.rejects.toMatchObject({ code: 'MISSING_CREDENTIAL' })
})
it('prefers explicit config over env for key and base URL', async () => {

View File

@@ -143,6 +143,29 @@ describe('request-level dynamic configuration', () => {
])
})
it('sends the whole last-good snapshot when a rejected one changed both the key and the URL', async () => {
vi.stubEnv('DEEPSEEK_API_KEY', '')
const dir = await home()
const good = await mockServer([{ kind: 'sse', events: textEvents }])
const rejected = await mockServer([{ kind: 'sse', events: textEvents }])
const { ctx } = await boot(dir, { apiKey: 'good-key', baseURL: good.url })
// One snapshot moves the endpoint AND the literal key, and fails the
// resolve step beyond the schema (duplicate catalog ids).
await ctx.settings.update(NS, {
apiKey: 'rejected-key',
baseURL: rejected.url,
models: [{ id: 'dup' }, { id: 'dup' }],
})
await prompt(ctx)
// The rejected generation contributes nothing: not its endpoint, and — the
// regression this pins — not its key either.
expect(rejected.requests).toHaveLength(0)
expect(good.requests).toHaveLength(1)
expect(good.headers[0]?.authorization).toBe('Bearer good-key')
})
it('falls back to the composition entry when settings detach', async () => {
vi.stubEnv('DEEPSEEK_API_KEY', '')
const dir = await home()

View File

@@ -41,12 +41,15 @@ afterEach(async () => {
})
async function loadComposition(
options: { withDynamic: boolean; baseURL: string },
options: { withDynamic: boolean; baseURL: string; reuseRoot?: string },
): Promise<{ ctx: Context; settingsPath: string; envPath: string }> {
root = await mkdtemp(join(tmpdir(), 'dsh-llm-composition-'))
// A reused root is the restart case: the same harness home, its documents
// exactly as the previous process left them.
const fresh = options.reuseRoot === undefined
root = options.reuseRoot ?? await mkdtemp(join(tmpdir(), 'dsh-llm-composition-'))
const settingsPath = join(root, 'settings.yaml')
const envPath = join(root, '.env')
if (options.withDynamic) {
if (options.withDynamic && fresh) {
await writeFile(settingsPath, '# personal settings\n')
await writeFile(envPath, 'DEEPSEEK_API_KEY=boot-key\n')
}
@@ -129,6 +132,35 @@ describe('llm-deepseek real dynamic composition', () => {
expect(serverB.headers[0]?.authorization).toBe('Bearer rotated-key')
})
it('keeps a stored key writable and rotatable across a real restart', async () => {
// No ambient DEEPSEEK_API_KEY: the shipped surfaces no longer hoist
// $DSH_HOME/.env into process.env, so a stored key must stay file-sourced.
vi.stubEnv('DEEPSEEK_API_KEY', '')
const first = await mockServer([{ kind: 'sse', events: textEvents }])
const second = await mockServer([{ kind: 'sse', events: textEvents }])
const boot = await loadComposition({ withDynamic: true, baseURL: first.url })
const home = root!
await boot.ctx.get('credentials')!.set(KEY_REF, 'stored-by-ui')
expect(await boot.ctx.get('credentials')!.describe(KEY_REF))
.toEqual({ configured: true, source: 'file', writable: true })
await assemble(boot.ctx, { model: 'deepseek-v4-flash', messages: [] })
expect(first.headers[0]?.authorization).toBe('Bearer stored-by-ui')
await boot.ctx.fiber.dispose()
context = undefined
// Restart over the same harness home.
const restarted = await loadComposition({ withDynamic: true, baseURL: second.url, reuseRoot: home })
const credentials = restarted.ctx.get('credentials')!
// The stored key is still the provider's own writable file entry — not a
// read-only launch override, which is what hoisting it would have made it.
expect(await credentials.resolve(KEY_REF)).toEqual({ value: 'stored-by-ui', source: 'file' })
expect(await credentials.describe(KEY_REF)).toEqual({ configured: true, source: 'file', writable: true })
// Rotation still works after the restart, and the next request uses it.
await credentials.set(KEY_REF, 'rotated-after-restart')
await assemble(restarted.ctx, { model: 'deepseek-v4-flash', messages: [] })
expect(second.headers[0]?.authorization).toBe('Bearer rotated-after-restart')
})
it('boots the same adapter without settings or credentials entries on entry config alone', async () => {
vi.stubEnv('DEEPSEEK_API_KEY', '')
const server = await mockServer([{ kind: 'sse', events: textEvents }])

View File

@@ -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-pi-ai/README.md
README.md: f2d030087c9cd704724ce4adf38f3f8111e87063
README.zh.md: bcecd3394693a95e1fe2127b44e6f60030518cca
README.md: 75442e2f1f6578ed458d05302b1cb6b063e26092
README.zh.md: 3baed4e30bbce6ce21c52da79369ad096bbbb752

View File

@@ -8,7 +8,7 @@ The package root exposes the Cordis plugin contract and `PiAiAdapter`; profile r
## Config
Configure credentials and deployment-specific transport settings per provider, keyed by the provider route itself. Prefer `apiKeyEnv` — a credential *reference* resolved per request — over a literal `apiKey`, so no secret enters this file; omitting both delegates authentication to pi-ai's provider-native ambient discovery. `baseURL` overrides only the endpoint of the selected catalog model, preserving its API family and compatibility metadata, so private proxies such as `https://proxy.example.com:8443` remain supported.
Configure credentials and deployment-specific transport settings per provider, keyed by the provider route itself. Prefer `apiKeyEnv` — a credential *reference* resolved per request — over a literal `apiKey`, so no secret enters this file. Omitting **both** is what delegates authentication to pi-ai's provider-native ambient discovery; a configured reference that resolves to nothing fails the request with `MISSING_CREDENTIAL` instead, because falling through would authenticate with whatever unrelated key the environment happens to hold. `baseURL` overrides only the endpoint of the selected catalog model, preserving its API family and compatibility metadata, so private proxies such as `https://proxy.example.com:8443` remain supported.
```yaml
- id: llm
@@ -41,7 +41,7 @@ Each dict key must exist in pi-ai's installed catalog; the dict shape makes dupl
The adapter reads its profiles through a thunk **once per operation** instead of freezing them at construction. The plugin registers the `llm-pi-ai` namespace on the optional `ctx.settings` seam with this same `Config` schema and its `cordis.yml` entry as the composition `base`, and because `providers` is a dict, the base and the user's `llm-pi-ai:` settings section merge **per provider**: a user can add a route, override one field of a composition route, or point a route at another proxy, all effective on the next request with no restart. Without a mounted settings service the entry config alone drives the adapter, unchanged.
Credentials resolve per stream call: a non-empty literal `apiKey` wins, then `apiKeyEnv` through the optional `ctx.credentials` seam (`$DSH_HOME/.env` under the live environment; the raw environment variable without a mounted seam), then pi-ai's ambient discovery. The route set and each route's captured retry policy are the registration-level facts: when either changes, the plugin re-registers the same adapter instance in one synchronous section, so `ctx.llm.listProviders()` and `providerRetryPolicy()` always reflect the current configuration. A live settings snapshot naming an unknown provider (or failing any other resolver bound) keeps the last good profiles and logs the failure; the entry config itself still fails plugin load.
Credentials resolve per stream call: a non-empty literal `apiKey` wins, then `apiKeyEnv` through the optional `ctx.credentials` seam (`$DSH_HOME/.env` under the live environment; exactly that variable without a mounted seam). A profile naming no credential at all — and only that case — defers to pi-ai's ambient discovery. The route set and each route's captured retry policy are the registration-level facts: when either changes, the plugin replaces its registration atomically (same adapter instance, candidate set validated first), so a route another adapter already owns leaves the previous routes serving and reverting to a working configuration re-applies. Provider key order never counts as a change. A live settings snapshot naming an unknown provider (or failing any other resolver bound) keeps the last good profiles and logs the failure; the entry config itself still fails plugin load.
The adapter exposes each configured provider's installed pi-ai models through `ctx.llm.listModels(provider)`. This is provider-neutral selector metadata derived from `getModels(provider)`; request-time resolution still performs the authoritative catalog lookup, so discovery does not create a second model registry. `ctx.llm.resolveModelInfo(provider, model)` performs that exact descriptor lookup once and returns its identity, context window, and selectable thinking levels, keeping authoritative metadata on the route-owning adapter rather than its consumers.
@@ -77,7 +77,7 @@ pi-ai installs several provider SDKs and lazy-loads the one selected by the cata
## Testing
Unit tests use pi-ai catalog models redirected to local mock servers and cover provider/profile routing, one wire request per adapter call, idle-timeout response termination, caller abort, native API selection, endpoint overrides, attribution, conversion, replay-state validation, and cross-provider/model replay within one adapter instance. `tests/dynamic-config.spec.ts` drives real settings-local and credentials-local providers: a settings-born route registers live and drops when the user layer resets, `apiKeyEnv` credentials rotate between requests, and an unknown-provider snapshot keeps the last good profiles. Real-API coverage remains key-gated under `pnpm run test:e2e`.
Unit tests use pi-ai catalog models redirected to local mock servers and cover provider/profile routing, one wire request per adapter call, idle-timeout response termination, caller abort, native API selection, endpoint overrides, attribution, conversion, replay-state validation, and cross-provider/model replay within one adapter instance. `tests/dynamic-config.spec.ts` drives real settings-local and credentials-local providers: a settings-born route registers live and drops when the user layer resets, `apiKeyEnv` credentials rotate between requests, and an unknown-provider snapshot keeps the last good profiles. `tests/loader-composition.spec.ts` boots the dormant posture from a test-only `cordis.yml` through the actual Loader and registers its route from an on-disk `settings.yaml` edit. Real-API coverage remains key-gated under `pnpm run test:e2e`.
## Model Experience

View File

@@ -8,7 +8,7 @@
## 配置
按提供方配置凭据与部署特定传输设置,并以提供方路由本身为键。优先使用 `apiKeyEnv`——按请求解析的凭据*引用*——而非字面 `apiKey`,让机密不进入该文件;两者都省略则把认证委托给 pi-ai 的提供方原生环境发现。`baseURL` 只会覆盖所选 catalog 模型的端点,保留其 API 家族与兼容性元数据,因此仍支持 `https://proxy.example.com:8443` 等私有 proxy。
按提供方配置凭据与部署特定传输设置,并以提供方路由本身为键。优先使用 `apiKeyEnv`——按请求解析的凭据*引用*——而非字面 `apiKey`,让机密不进入该文件。**两者**都省略,才会把认证委托给 pi-ai 的提供方原生环境发现;已配置却解析不出任何值的引用则相反,会让请求以 `MISSING_CREDENTIAL` 失败,因为放行下去就会用环境里恰好持有的某个无关密钥完成认证`baseURL` 只会覆盖所选 catalog 模型的端点,保留其 API 家族与兼容性元数据,因此仍支持 `https://proxy.example.com:8443` 等私有 proxy。
```yaml
- id: llm
@@ -41,7 +41,7 @@
适配器经由一个 thunk **每操作读取一次** profile而非在构造期冻结。插件在可选的 `ctx.settings` seam 上用同一份 `Config` schema 注册 `llm-pi-ai` namespace并以其 `cordis.yml` 条目为组合 `base`;由于 `providers` 是字典base 与用户的 `llm-pi-ai:` settings 分节**按提供方**合并:用户可以新增路由、覆盖组合路由的单个字段,或把路由指向另一个 proxy全部在下一次请求生效无需重启。未挂载 settings 服务时,仅由 entry 配置驱动适配器,行为不变。
凭据按每次 stream 调用解析:非空的字面 `apiKey` 优先,其次经可选的 `ctx.credentials` seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`;未挂载 seam 时为原始环境变量),最后是 pi-ai 的环境发现。路由集合与每条路由捕获的重试策略是注册级事实:两者任一变化时,插件都会在一个同步区段内重新注册同一适配器实例,因此 `ctx.llm.listProviders()``providerRetryPolicy()` 始终反映当前配置。存活 settings 快照若点名未知提供方(或违反任何其他 resolver 约束),则保留最后可用 profile 并记录失败entry 配置本身仍会使插件加载失败。
凭据按每次 stream 调用解析:非空的字面 `apiKey` 优先,其次经可选的 `ctx.credentials` seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`;未挂载 seam 时恰好读取该环境变量)。只有完全没有点名任何凭据的 profile——仅限这一种情况——才交给 pi-ai 的环境发现。路由集合与每条路由捕获的重试策略是注册级事实:两者任一变化时,插件都会原子地替换自己的注册同一适配器实例,候选集合先经校验),因此某条路由若已被另一适配器占有,先前的路由会继续服务,而改回可用配置时注册会重新生效。提供方键的顺序绝不算作变化。存活 settings 快照若点名未知提供方(或违反任何其他 resolver 约束),则保留最后可用 profile 并记录失败entry 配置本身仍会使插件加载失败。
适配器通过 `ctx.llm.listModels(provider)` 公开每个已配置提供方已安装的 pi-ai 模型。这是从 `getModels(provider)` 派生的提供方无关 selector 元数据;请求时解析仍会执行权威 catalog 查找,因此发现不会创建第二个模型注册表。`ctx.llm.resolveModelInfo(provider, model)` 会执行一次精确 descriptor 查找,并返回其身份、上下文窗口和可选思考级别,让权威元数据保留在拥有路由的适配器上,而非消费方。
@@ -77,7 +77,7 @@ pi-ai 会安装多个提供方 SDK并延迟加载 catalog 模型所选的 SDK
## 测试
单元测试使用重定向到本地 mock 服务器的 pi-ai catalog 模型覆盖提供方profile 路由、每次适配器调用只发起一个协议请求、idle-timeout 响应终止、调用方 abort、原生 API 选择、端点覆盖、归因、转换、回放状态验证,以及一个适配器实例内的跨提供方/模型回放。`tests/dynamic-config.spec.ts` 驱动真实的 settings-local 与 credentials-local providersettings 里新生的路由实时完成注册,并在用户层重置时随之移除,`apiKeyEnv` 凭据在两次请求之间轮换,点名未知提供方的快照则保留最后可用 profile。真实 API 覆盖仍需 key 才会启用,并通过 `pnpm run test:e2e` 运行。
单元测试使用重定向到本地 mock 服务器的 pi-ai catalog 模型覆盖提供方profile 路由、每次适配器调用只发起一个协议请求、idle-timeout 响应终止、调用方 abort、原生 API 选择、端点覆盖、归因、转换、回放状态验证,以及一个适配器实例内的跨提供方/模型回放。`tests/dynamic-config.spec.ts` 驱动真实的 settings-local 与 credentials-local providersettings 里新生的路由实时完成注册,并在用户层重置时随之移除,`apiKeyEnv` 凭据在两次请求之间轮换,点名未知提供方的快照则保留最后可用 profile。`tests/loader-composition.spec.ts` 从仅测试用的 `cordis.yml` 出发,经真实 Loader 拉起休眠姿态,并从磁盘上的一次 `settings.yaml` 编辑注册出它的路由。真实 API 覆盖仍需 key 才会启用,并通过 `pnpm run test:e2e` 运行。
## 模型体验

View File

@@ -41,9 +41,11 @@ export interface PiAiAdapterOptions {
/**
* Resolve the credential for one already-resolved profile; called once per
* stream call and frozen for that call. `undefined` defers to pi-ai's
* provider-native ambient discovery.
* provider-native ambient discovery, which the plugin allows only for a
* profile naming no credential at all; a named reference that misses throws
* `LlmError` `MISSING_CREDENTIAL` rather than falling back.
*/
resolveApiKey: (profile: ResolvedPiAiProviderProfile) => Promise<string | undefined>
resolveApiKey: (provider: string, profile: ResolvedPiAiProviderProfile) => Promise<string | undefined>
}
/**
@@ -180,7 +182,7 @@ export class PiAiAdapter extends LlmAdapter {
model,
options.reasoningEffort ?? profile.reasoning,
)
const apiKey = await this.config.resolveApiKey(profile)
const apiKey = await this.config.resolveApiKey(options.provider, profile)
const consumer = new AbortController()
const upstream = options.signal === undefined

View File

@@ -30,7 +30,8 @@
import type { Context } from 'cordis'
import { getBuiltinProviders } from '@earendil-works/pi-ai/providers/all'
import type {} from '@deepseek-ai/dsh-llm'
import { LlmError } from '@deepseek-ai/dsh-llm'
import type { AdapterRegistrationHandle } from '@deepseek-ai/dsh-llm'
import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
import { PiAiAdapter } from './adapter.ts'
import { Config, resolveProfiles } from './config.ts'
@@ -46,9 +47,15 @@ export const inject = ['llm']
const NS = settingsNamespace('llm-pi-ai')
/** The registry captures these per route; a change here must re-register. */
/**
* The registry captures these per route; a change here must re-register.
* Sorted by provider so a settings document that merely reorders its keys is
* not mistaken for a route change.
*/
function registrationFacts(profiles: ReadonlyMap<string, ResolvedPiAiProviderProfile>): unknown {
return [...profiles.entries()].map(([provider, profile]) => ({ provider, retryPolicy: profile.retryPolicy }))
return [...profiles.entries()]
.map(([provider, profile]) => ({ provider, retryPolicy: profile.retryPolicy }))
.sort((left, right) => left.provider.localeCompare(right.provider))
}
/** Register one generic pi-ai adapter for all configured provider routes. */
@@ -77,17 +84,31 @@ export function apply(ctx: Context, config: Config): void {
}
profiles()
const resolveApiKey = async (profile: ResolvedPiAiProviderProfile): Promise<string | undefined> => {
const resolveApiKey = async (
provider: string,
profile: ResolvedPiAiProviderProfile,
): Promise<string | undefined> => {
if (profile.apiKey !== undefined) return profile.apiKey
const ref = profile.apiKeyEnv
// Only a profile that names no credential at all defers to pi-ai's
// provider-native discovery. Once one is named, a miss must fail loud:
// handing pi-ai `undefined` would let it pick up an unrelated ambient key
// (OPENAI_API_KEY and friends), billing another tenant for a request the
// deployment meant to authenticate differently.
if (ref === undefined) return undefined
const credentials = ctx.get('credentials')
if (credentials !== undefined) return (await credentials.resolve(ref))?.value
// Without the seam, keep an ambient fallback so a plain cordis.yml
// composition works from the environment alone; an empty variable defers
// to pi-ai's own provider-native discovery like an absent one.
const ambient = process.env[ref]
return ambient !== undefined && ambient.length > 0 ? ambient : undefined
const hit = credentials !== undefined
? (await credentials.resolve(ref))?.value
// Without the seam, read exactly the named variable so a plain
// cordis.yml composition works from the environment alone.
: process.env[ref]
if (hit !== undefined && hit.length > 0) return hit
throw new LlmError(
`llm-pi-ai: no credential for provider route "${provider}"; its profile resolves ${ref}, which is not`
+ ` set — store ${ref} through the credentials service (the web Models page writes it) or export it,`
+ ' and remove apiKeyEnv only if this provider should authenticate from pi-ai\'s own environment discovery',
'MISSING_CREDENTIAL',
)
}
const adapter = new PiAiAdapter({ profiles, resolveApiKey })
@@ -104,18 +125,29 @@ export function apply(ctx: Context, config: Config): void {
// even when a swap runs inside the scoped settings callback below. A bare
// mount (zero routes) is the dormant posture: nothing registers until a
// settings section supplies profiles, and routes drop when it empties.
let disposeRoutes: (() => void) | undefined
let registration: AdapterRegistrationHandle | undefined
let registeredFacts: unknown
const ensureRegistrationFacts = (): void => {
const facts = registrationFacts(profiles())
if (deepEqualJson(facts, registeredFacts)) return
// The registry captures the route set and each route's retry policy at
// registration: swap the registration in one synchronous section (same
// adapter instance, no NO_ADAPTER window).
disposeRoutes?.()
disposeRoutes = undefined
// registration, so a change to either must re-register. The swap is
// atomic (same adapter instance, validated before anything moves): a
// conflicting route leaves the previous routes serving requests, and
// `registeredFacts` only advances once the registry actually holds the
// new set — so returning to a working configuration always re-applies.
const routes = [...profiles().keys()]
if (routes.length > 0) disposeRoutes = ctx.llm.registerAdapter(routes, adapter)
if (registration === undefined) {
// Dormant bare mount: nothing is registered until a section supplies
// profiles, and an empty section keeps it that way.
if (routes.length === 0) {
registeredFacts = facts
return
}
registration = ctx.llm.registerAdapter(routes, adapter)
} else {
registration.replace(routes)
}
registeredFacts = facts
}
ensureRegistrationFacts()

View File

@@ -27,7 +27,7 @@ async function harness(baseURL: string, overrides: Record<string, unknown> = {})
function adapterOf(providers: Record<string, LlmPiAi.PiAiProviderProfile>): PiAiAdapter {
return new PiAiAdapter({
profiles: () => resolveProfiles(providers),
resolveApiKey: profile => Promise.resolve(profile.apiKey),
resolveApiKey: (_provider, profile) => Promise.resolve(profile.apiKey),
})
}
@@ -385,13 +385,19 @@ describe('provider profile lifecycle', () => {
expect(server.headers[0]?.authorization).toBe('Bearer custom-ref-key')
})
it('treats an empty apiKeyEnv variable as absent and defers to SDK ambient discovery', async () => {
it('fails a named-but-missing apiKeyEnv instead of using another ambient key', async () => {
// The exact confusion this guards: the named reference is empty while an
// unrelated provider key sits in the environment. Deferring to pi-ai's own
// discovery here would authenticate as another tenant.
vi.stubEnv('PI_CUSTOM_REF_KEY', '')
vi.stubEnv('DEEPSEEK_API_KEY', 'ambient-key')
const server = await mockServer([{ events: textEvents }])
const ctx = await harness(server.url, { apiKey: undefined, apiKeyEnv: 'PI_CUSTOM_REF_KEY' })
await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
expect(server.headers[0]?.authorization).toBe('Bearer ambient-key')
await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }))
.rejects.toMatchObject({ code: 'MISSING_CREDENTIAL' })
await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }))
.rejects.toThrow(/provider route "deepseek".*PI_CUSTOM_REF_KEY/s)
expect(server.requests).toHaveLength(0)
})
it('validates empty, unknown, legacy-shaped, and explicitly blank profiles', () => {

View File

@@ -3,7 +3,7 @@ import { Context } from 'cordis'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import LlmService from '@deepseek-ai/dsh-llm'
import LlmService, { LlmAdapter } from '@deepseek-ai/dsh-llm'
import { credentialRef } from '@deepseek-ai/dsh-credentials'
import { CredentialsLocal } from '@deepseek-ai/dsh-credentials-local'
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
@@ -14,6 +14,14 @@ import { closeMockServers, mockServer, textEvents } from './mock-server.ts'
const NS = settingsNamespace('llm-pi-ai')
/** Minimal foreign adapter: only needs to own a route the pi-ai plugin then wants. */
class StubAdapter extends LlmAdapter {
override async * stream(): AsyncIterable<never> {
throw new Error('stub adapter must never stream')
}
}
const cleanups: Array<() => Promise<void>> = []
afterEach(async () => {
@@ -147,4 +155,45 @@ describe('request-level dynamic profiles', () => {
await ctx.settings.update(NS, { providers: { 'not-a-real-provider': {} } })
expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai'])
})
it('keeps serving its routes when a settings-born route collides with another adapter', async () => {
const dir = await home()
const server = await mockServer([{ events: textEvents }, { events: textEvents }])
const ctx = await boot(dir, { providers: { openai: { apiKey: 'pk', baseURL: `${server.url}/v1` } } })
// Another adapter owns `anthropic`; the registry must refuse to hand it over.
ctx.llm.registerAdapter(['anthropic'], new StubAdapter())
await ctx.settings.update(NS, {
providers: {
openai: { apiKey: 'pk', baseURL: `${server.url}/v1` },
anthropic: { apiKey: 'other' },
},
})
// The conflicting swap was refused whole: the previous route set still
// owns openai (an eager dispose would have dropped it), and anthropic
// still belongs to its original adapter.
expect(ctx.llm.listProviders().map(provider => provider.id).sort()).toEqual(['anthropic', 'openai'])
const result = await assemble(ctx, { provider: 'openai', model: 'gpt-4.1', messages: [] })
expect(result.finish.kind).toBe('error')
expect(server.paths).toEqual(['/v1/responses'])
// Reverting to the working configuration re-applies, even though its
// facts equal the ones the registry already holds.
await ctx.settings.replace(NS, {})
expect(ctx.llm.listProviders().map(provider => provider.id).sort()).toEqual(['anthropic', 'openai'])
await assemble(ctx, { provider: 'openai', model: 'gpt-4.1', messages: [] })
expect(server.paths).toEqual(['/v1/responses', '/v1/responses'])
})
it('ignores a settings document that merely reorders its provider keys', async () => {
const dir = await home()
const ctx = await boot(dir, { providers: { openai: {}, anthropic: {} } })
const before = ctx.llm.listProviders().map(provider => provider.id)
// Same routes, different YAML key order: nothing about the registration
// changed, so no swap should happen at all.
await ctx.settings.update(NS, { providers: { anthropic: {}, openai: {} } })
expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(before)
})
})

View File

@@ -0,0 +1,116 @@
/**
* Real-composition guard for the dormant pi-ai posture: LlmService,
* settings-local, credentials-local, and a bare `llm-pi-ai` row boot from a
* test-only cordis.yml through the actual Loader + Include path, an external
* edit of settings.yaml registers the route live, and the next request
* carries the credential the .env supplies. A hand-mounted `ctx.plugin` cannot
* catch Loader export-shape failures, which is why the twin adapter has the
* same guard.
*/
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import Include from '@cordisjs/plugin-include'
import LlmService from '@deepseek-ai/dsh-llm'
import CredentialsLocal from '@deepseek-ai/dsh-credentials-local'
import SettingsLocal from '@deepseek-ai/dsh-settings-local'
import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai'
import { assemble } from './assemble.ts'
import { closeMockServers, mockServer, textEvents } from './mock-server.ts'
let root: string | undefined
let context: Context | undefined
afterEach(async () => {
await context?.fiber.dispose()
context = undefined
if (root !== undefined) await rm(root, { recursive: true, force: true })
root = undefined
await closeMockServers()
vi.unstubAllEnvs()
})
/** Boot the dormant composition: a bare `llm-pi-ai` row with no config at all. */
async function loadComposition(): Promise<{ ctx: Context; settingsPath: string }> {
root = await mkdtemp(join(tmpdir(), 'dsh-pi-composition-'))
const settingsPath = join(root, 'settings.yaml')
await writeFile(settingsPath, '# personal settings\n')
await writeFile(join(root, '.env'), 'PI_COMPOSITION_KEY=key-from-store\n')
const configPath = join(root, 'cordis.yml')
await writeFile(configPath, [
'- id: llm',
" name: 'test-llm-service'",
'- id: settings',
" name: '@deepseek-ai/dsh-settings-local'",
' config:',
` path: ${JSON.stringify(settingsPath)}`,
' debounceMs: 10',
'- id: credentials',
" name: '@deepseek-ai/dsh-credentials-local'",
' config:',
` path: ${JSON.stringify(join(root, '.env'))}`,
' debounceMs: 10',
'- id: llm-pi-ai',
" name: '@deepseek-ai/dsh-llm-pi-ai'",
'',
].join('\n'))
const ctx = new Context()
context = ctx
ctx.baseUrl = pathToFileURL(root).href + '/'
await ctx.plugin(Loader)
ctx.loader.builtins.include = Include
const modules = new Map<string, unknown>([
['test-llm-service', LlmService],
['@deepseek-ai/dsh-settings-local', SettingsLocal],
['@deepseek-ai/dsh-credentials-local', CredentialsLocal],
['@deepseek-ai/dsh-llm-pi-ai', LlmPiAi],
])
ctx.loader.internal = {
version: 'v2',
async import(specifier: string) {
if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
return modules.get(specifier)
},
} as unknown as NonNullable<typeof ctx.loader.internal>
await ctx.loader.create({
name: 'cordis:include',
config: { path: pathToFileURL(configPath).href },
})
await ctx.loader.await()
return { ctx, settingsPath }
}
describe('llm-pi-ai real dormant composition', () => {
it('boots with zero routes and registers one the moment settings supply a profile', async () => {
vi.stubEnv('PI_COMPOSITION_KEY', '')
const server = await mockServer([{ events: textEvents }])
const { ctx, settingsPath } = await loadComposition()
// The shipped posture: the adapter exists, no route does.
expect(ctx.llm.listProviders()).toEqual([])
// Exactly what the web Models page leaves on disk.
await writeFile(settingsPath, [
'llm-pi-ai:',
' providers:',
' deepseek:',
' apiKeyEnv: PI_COMPOSITION_KEY',
` baseURL: ${server.url}`,
'',
].join('\n'))
await vi.waitFor(() => {
expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['deepseek'])
}, { timeout: 5000 })
const result = await assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] })
expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }])
expect(server.headers[0]?.authorization).toBe('Bearer key-from-store')
})
})

View File

@@ -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/README.md
README.md: 12bf3ca9901a7027111761a6d523862f9f7e150b
README.zh.md: d39b6c518f332e0778e10b73bdcf1ddd25433ab3
README.md: b338f4d07ae4c5e3dd9a04ad0765fb8b6d6a99a7
README.zh.md: 11d4c92573e2bdba74d1f7e04b445a9039824267

View File

@@ -10,7 +10,7 @@ An adapter registry plus a single streaming call surface, interceptable via a wa
### Public API
- `ctx.llm.registerAdapter(providers: string[], adapter: LlmAdapter): () => void` Register one adapter instance for the given provider routes. Registration is all-or-nothing, and is disposed with the calling fiber.
- `ctx.llm.registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle` Register one adapter instance for the given provider routes. Registration is all-or-nothing, and is disposed with the calling fiber. The returned disposer also carries `replace(providers)`: the candidate route set is validated in full before anything moves, so a conflict with another adapter leaves the current routes registered and serving, and the swap itself is one synchronous section with no observable gap. `replace([])` is legal — a registration holding zero routes — unlike an empty initial registration.
- `ctx.llm.listProviders(): LlmProviderInfo[]` Describe registered provider routes in registration order.
- `ctx.llm.registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): () => void` Declare provider routes an adapter plugin can activate through configuration — registered or dormant — each naming its owning settings namespace and the path to its profile inside that section. All-or-nothing (`INVALID_DIRECTORY`/`DUPLICATE_DIRECTORY`), disposed with the calling fiber.
- `ctx.llm.listConfigurableProviders(): LlmConfigurableProvider[]` List the declared directory in declaration order; configuration surfaces merge it with `listProviders()` to mark each entry live or dormant.

View File

@@ -10,7 +10,7 @@
### 公开 API
- `ctx.llm.registerAdapter(providers: string[], adapter: LlmAdapter): () => void` 为给定提供方路由注册一个适配器实例。注册要么全部成功,要么全部不生效,并且会随调用 fiber 一起 dispose资源释放
- `ctx.llm.registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle` 为给定提供方路由注册一个适配器实例。注册要么全部成功,要么全部不生效,并且会随调用 fiber 一起 dispose资源释放返回的释放器还携带 `replace(providers)`:候选路由集合会在任何东西变动之前完整校验,因此与另一适配器冲突时,当前路由保持注册且继续服务,而替换本身是一个同步区段,不存在可观察的空档。`replace([])` 合法——一个持有零条路由的注册——这与空的初始注册不同。
- `ctx.llm.listProviders(): LlmProviderInfo[]` 按注册顺序描述已注册提供方路由。
- `ctx.llm.registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): () => void` 声明适配器插件可通过配置激活的提供方路由——无论已注册还是休眠——每个条目指明其所属 settings namespace以及 profile 在该分节内的路径。要么全部成功,要么全部不生效(`INVALID_DIRECTORY`/`DUPLICATE_DIRECTORY`),并随调用 fiber dispose。
- `ctx.llm.listConfigurableProviders(): LlmConfigurableProvider[]` 按声明顺序列出已声明的目录;配置界面将其与 `listProviders()` 合并,为每个条目标注存活或休眠。

View File

@@ -196,6 +196,26 @@ export abstract class LlmAdapter {
abstract stream(options: GenerateOptions): AsyncIterable<StreamChunk>
}
/**
* What {@link LlmService.registerAdapter} returns: the disposer, plus an
* atomic route replacement for the same adapter instance.
*/
export interface AdapterRegistrationHandle {
/** Release every route this registration currently holds. */
(): void
/**
* Replace this registration's routes with `providers`, keeping the same
* adapter instance. The candidate set is validated in full first — a
* conflict with another adapter, an invalid name, or bad provider metadata
* throws and leaves the current routes untouched — and the swap itself is
* one synchronous section, so no request can observe a gap. An empty array
* is legal here (a settings section that emptied holds zero routes while
* staying registered), unlike an empty initial registration.
* @param providers - the complete next route set for this registration.
*/
replace(providers: string[]): void
}
/**
* The abstract `llm` service: an adapter registry plus a streaming model-call
* surface, interceptable via the `llm/stream` waterfall.
@@ -235,41 +255,74 @@ export class LlmService extends Service {
* Disposed with the fiber.
* @param providers - every provider route this adapter should serve.
* @param adapter - the adapter that streams calls for those providers.
* @returns the disposer that unregisters all of them.
* @returns the disposer, carrying {@link AdapterRegistrationHandle.replace}.
*/
registerAdapter(providers: string[], adapter: LlmAdapter): () => void {
registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle {
// The routes this registration currently holds; `replace` rewrites it, and
// the disposer releases whatever it holds at disposal time.
const owned = new Set<string>()
const dispose = this.ctx.effect(function* (this: LlmService) {
if (providers.length === 0) throw new LlmError('an adapter must register at least one provider', 'INVALID_ADAPTER')
const unique = new Set<string>()
const registrations: AdapterRegistration[] = []
for (const provider of providers) {
if (provider.length === 0) throw new LlmError('adapter provider names must be non-empty', 'INVALID_ADAPTER')
if (unique.has(provider) || this.adapters.has(provider)) {
throw new LlmError(`an adapter for provider "${provider}" is already registered`, 'DUPLICATE_ADAPTER')
}
const info = adapter.providerInfo(provider)
if (typeof info.id !== 'string' || info.id !== provider || typeof info.name !== 'string' || info.name.length === 0) {
throw new LlmError(`adapter metadata for provider "${provider}" must preserve its id and have a non-empty name`, 'INVALID_ADAPTER')
}
unique.add(provider)
const retryPolicy = adapter.providerRetryPolicy(provider)
?? resolveRetryPolicy(undefined, `llm: provider "${provider}" retryPolicy`)
registrations.push({
adapter,
provider: { id: info.id, name: info.name },
retryPolicy,
})
}
for (const registration of registrations) this.adapters.set(registration.provider.id, registration)
this.emitAdaptersUpdated()
this.commitRoutes(owned, this.prepareRoutes(providers, adapter, owned))
yield () => {
for (const provider of providers) this.adapters.delete(provider)
for (const provider of owned) this.adapters.delete(provider)
owned.clear()
this.emitAdaptersUpdated()
}
}.bind(this), 'llm.registerAdapter()')
// ctx.effect's disposer returns Promise<void>; our disposer API is
// synchronous fire-and-forget — discard the (always-resolved) promise.
return () => void dispose()
const handle = (() => void dispose()) as AdapterRegistrationHandle
handle.replace = (next: string[]): void => {
this.commitRoutes(owned, this.prepareRoutes(next, adapter, owned))
}
return handle
}
/**
* Validate one candidate route set for `adapter`, treating routes this
* registration already holds as available. Nothing is mutated: a rejected
* candidate leaves the registry exactly as it was.
*/
private prepareRoutes(providers: string[], adapter: LlmAdapter, owned: ReadonlySet<string>): AdapterRegistration[] {
const unique = new Set<string>()
const registrations: AdapterRegistration[] = []
for (const provider of providers) {
if (provider.length === 0) throw new LlmError('adapter provider names must be non-empty', 'INVALID_ADAPTER')
if (unique.has(provider) || (this.adapters.has(provider) && !owned.has(provider))) {
throw new LlmError(`an adapter for provider "${provider}" is already registered`, 'DUPLICATE_ADAPTER')
}
const info = adapter.providerInfo(provider)
if (typeof info.id !== 'string' || info.id !== provider || typeof info.name !== 'string' || info.name.length === 0) {
throw new LlmError(`adapter metadata for provider "${provider}" must preserve its id and have a non-empty name`, 'INVALID_ADAPTER')
}
unique.add(provider)
const retryPolicy = adapter.providerRetryPolicy(provider)
?? resolveRetryPolicy(undefined, `llm: provider "${provider}" retryPolicy`)
registrations.push({
adapter,
provider: { id: info.id, name: info.name },
retryPolicy,
})
}
return registrations
}
/**
* Swap this registration's routes for the prepared ones in one synchronous
* section, so no observer can see the registry between the release and the
* re-registration. The route set's one mutation point is also where
* `llm/adapters-updated` is published, so a `replace` announces itself
* exactly like a first registration.
*/
private commitRoutes(owned: Set<string>, registrations: readonly AdapterRegistration[]): void {
for (const provider of owned) this.adapters.delete(provider)
owned.clear()
for (const registration of registrations) {
this.adapters.set(registration.provider.id, registration)
owned.add(registration.provider.id)
}
this.emitAdaptersUpdated()
}
/**

View File

@@ -10,10 +10,10 @@
import { Context, Service } from 'cordis'
import z from 'schemastery'
import { watch as chokidarWatch } from 'chokidar'
import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
import { mkdir, readFile } from 'node:fs/promises'
import { dirname, extname, join, resolve } from 'node:path'
import { Document, parseDocument } from 'yaml'
import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
import { Settings, deepEqualJson, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
@@ -96,23 +96,6 @@ function isENOENT(error: unknown): boolean {
return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT'
}
/** Whether an exclusive create failed because the path already exists. */
function isEEXIST(error: unknown): boolean {
return (error as NodeJS.ErrnoException | null)?.code === 'EEXIST'
}
/**
* Writer-lock protocol constants. These are robustness invariants of the
* cross-process write protocol, not deployment tunables: a holder rewrites one
* small document in milliseconds, so contention resolves well inside the
* retry deadline, and a lock older than the stale age can only belong to a
* crashed holder.
*/
const LOCK_RETRY_INITIAL_MS = 20
const LOCK_RETRY_MAX_MS = 200
const LOCK_TIMEOUT_MS = 2_000
const LOCK_STALE_MS = 5_000
/** File-backed settings provider (`settings.yaml`/`.json`). */
export class SettingsLocal extends Settings {
static Config: z<Config> = z.object({
@@ -199,8 +182,9 @@ export class SettingsLocal extends Settings {
private async persistSection(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void> {
// The writer lock's exclusive create needs the parent to exist before
// writeFileAtomic gets its own chance to create it.
await mkdir(dirname(this.spec.filename), { recursive: true })
await this.withWriterLock(async () => {
// 0700: the harness home holds user-private documents.
await mkdir(dirname(this.spec.filename), { recursive: true, mode: 0o700 })
await withFileLock(this.spec.filename, async () => {
// Read-modify-write: fold in any on-disk state this process has not
// observed yet — an external edit still inside the watcher debounce
// window, a change the watcher missed, or another process's write — so
@@ -212,59 +196,13 @@ export class SettingsLocal extends Settings {
? this.renderYaml(ns, section)
: this.renderJson(ns, section)
// 0600: a document that may hold personal values is never world-readable.
await writeFileAtomic(this.spec.filename, output, { mode: 0o600 })
await writeFileAtomic(this.spec.filename, output, { mode: 0o600, dirMode: 0o700 })
this.text = output
})
}
/**
* Hold the cross-process writer lock around one read-render-rename cycle.
* The lock is a `wx`-created sibling (`<file>.lock`); the rename-based
* commit keeps readers lock-free, so only writers contend. A lock older
* than {@link LOCK_STALE_MS} is a crashed holder and is broken with a
* warning; a live holder past {@link LOCK_TIMEOUT_MS} fails the write.
*/
private async withWriterLock<T>(operation: () => Promise<T>): Promise<T> {
const lockPath = `${this.spec.filename}.lock`
const deadline = Date.now() + LOCK_TIMEOUT_MS
let delay = LOCK_RETRY_INITIAL_MS
for (;;) {
try {
await writeFile(lockPath, `${process.pid}\n`, { mode: 0o600, flag: 'wx' })
break
} catch (error) {
if (!isEEXIST(error)) throw error
}
const ageMs = await this.lockAgeMs(lockPath)
// The holder released between the failed create and the stat: the lock
// is free right now, so retry without burning backoff or deadline.
if (ageMs === undefined) continue
if (ageMs > LOCK_STALE_MS) {
}, {
onStaleBreak: (lockPath) => {
this.ctx.logger.warn('settings-local: breaking a stale writer lock at %s', lockPath)
await rm(lockPath, { force: true })
continue
}
if (Date.now() >= deadline) {
throw new Error(`settings-local: timed out waiting for the writer lock at ${lockPath}`)
}
await new Promise(resolve => setTimeout(resolve, delay))
delay = Math.min(delay * 2, LOCK_RETRY_MAX_MS)
}
try {
return await operation()
} finally {
await rm(lockPath, { force: true })
}
}
/** Age of the writer lock, or `undefined` when it vanished after a failed create. */
private async lockAgeMs(lockPath: string): Promise<number | undefined> {
try {
return Date.now() - (await stat(lockPath)).mtimeMs
} catch (error) {
if (!isENOENT(error)) throw error
return undefined
}
},
})
}
override async* [Service.init](): AsyncGenerator<() => Promise<void> | void, void, void> {

View File

@@ -590,6 +590,20 @@ export abstract class Settings extends Service {
}
}
/**
* Value mirror of the `FiberState` members {@link isUnloading} compares
* against: a const enum has no runtime object to import, and the value is
* needed at runtime (same rationale as the CLI boot driver's mirror).
*/
const FIBER_DISPOSED = 4
const FIBER_UNLOADING = 5
/** Whether the consumer's own fiber is tearing down (not just losing the settings service). */
function isUnloading(ctx: Context): boolean {
const state: number = ctx.fiber.state
return state === FIBER_UNLOADING || state === FIBER_DISPOSED
}
/** Hooks a consumer hands to {@link installSettingsSection}. */
export interface SettingsSectionHooks<T> {
/**
@@ -630,6 +644,13 @@ export function installSettingsSection<T>(
const scope = sctx.settings.register(ns, schema, { base: entry })
hooks.setSource(() => scope.get())
sctx.effect(() => () => {
// This disposer runs for two different reasons. A settings provider
// detaching leaves the consumer running, so it must fall back to its
// composition entry and re-judge what it derived. The consumer's own
// unload runs it too — and there `onChange` would re-register routes
// and touch resources the teardown is releasing, so the fallback is
// pointless and the notification actively harmful.
if (isUnloading(ctx)) return
hooks.setSource(() => entry)
hooks.onChange()
})

View File

@@ -694,4 +694,34 @@ describe('installSettingsSection', () => {
})
expect(current()).toEqual({ theme: 'entry' })
})
it('stays silent when the consumer itself unloads', async () => {
const { ctx } = await boot({ doc: { 'helper-ns': { theme: 'user' } } })
const entry = { theme: 'entry' }
let current: () => { theme: string } = () => entry
const changes: string[] = []
const consumer = ctx.plugin({
inject: ['settings'],
apply: (child: Context) => {
installSettingsSection(child, settingsNamespace('helper-ns'), HelperSchema, entry, {
setSource: (source) => {
current = source
},
onChange: () => {
changes.push(current().theme)
},
})
},
})
await consumer
await vi.waitFor(() => {
expect(changes).toEqual(['user'])
})
// The consumer's own teardown must not re-derive anything: an onChange
// here would re-register routes and touch resources being released.
await consumer.dispose()
await new Promise(resolve => setTimeout(resolve, 20))
expect(changes).toEqual(['user'])
})
})

View File

@@ -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/ui/app-boot/README.md
README.md: 0282d3e9559d55c3fe5b07df133747750c06ebad
README.zh.md: b7121bbd288cd6e3f9ef2301de6018ceb380eb06
README.md: 47c5de35e6151b82f8d99c06618c42dfabe59f5e
README.zh.md: 9878567464b46865f9320582359f7baa0c97f30c

View File

@@ -26,8 +26,8 @@ This package carries no loader hooks and no dev-mode surface. The [`dsh` app](..
A developer's machine-local preferences live outside every repository in the Harness home (default `~/.dsh`, overridable via `$DSH_HOME`; the single root [`resolveDshHome`](../../util/paths/README.md) resolves), consumed by the `dsh` CLI's TUI surface ([`apps/cli`](../../../apps/cli/README.md)); the demo bins boot their committed trees verbatim. Two optional files:
- **`.env`** — loaded after the invoking directory's `.env`; `process.loadEnvFile` never overrides, so precedence is ambient environment > project `.env` > personal `.env`.
- **`config.yaml`** — loader overlay patches applied over the shipped default config, with the same semantics as an include entry's `patches` (the committed Code Mode overlay is the template): an id-targeted patch replaces the named entry's whole `config` (restate unchanged fields), `insert` adds entries, and `!!js` expressions interpolate at mount — so a personal `apiKey` can reference the personal `.env`. A patch naming an entry id absent from the booted tree is skipped with a loader warning. An empty or comments-only file throws (it parses to nothing, not to a list); disable the overlay with `[]` or by deleting the file.
- **`.env`** — the credential store of [`dsh-credentials-local`](../../credentials/credentials-local/README.md), read by that provider alone. No surface hoists it into `process.env`: doing so would make every stored key look like a read-only launch override on the next run, blocking rotation from the TUI and the web page. The environment layers are the ambient one and the invoking directory's `.env` (loaded by the bin; `process.loadEnvFile` never overrides), and a composition without the credential provider keeps resolving keys from those alone.
- **`config.yaml`** — loader overlay patches applied over the shipped default config, with the same semantics as an include entry's `patches` (the committed Code Mode overlay is the template): an id-targeted patch replaces the named entry's whole `config` (restate unchanged fields), `insert` adds entries, and `!!js` expressions interpolate at mount. A patch naming an entry id absent from the booted tree is skipped with a loader warning. An empty or comments-only file throws (it parses to nothing, not to a list); disable the overlay with `[]` or by deleting the file.
Subprocess test launchers point `DSH_HOME` at an isolated per-test directory so a developer's personal overlay can never leak into fixtures.

View File

@@ -26,8 +26,8 @@
开发者的机器本地偏好位于所有仓库之外的 Harness home 中(默认 `~/.dsh`,可由 `$DSH_HOME` 覆盖;统一由根级 [`resolveDshHome`](../../util/paths/README.md) 解析),并由 `dsh` CLI命令行界面的 TUI 界面([`apps/cli`](../../../apps/cli/README.md)使用demo bin 会原样启动仓库中提交的树。这里有两个可选文件:
- **`.env`**调用目录的 `.env` 之后加载;`process.loadEnvFile` 从不覆盖已有值,因此优先级为环境中的值 > 项目 `.env` > 个人 `.env`
- **`config.yaml`**:在发布的默认配置上应用 Loader overlay patch语义与 include 条目的 `patches` 相同(以仓库提交的 Code Mode overlay 为模板):按 id 定位的 patch 会替换对应条目的整个 `config`(未改字段也要重述),`insert` 会添加条目,`!!js` 表达式则在挂载时插值,因此个人 `apiKey` 可以引用个人 `.env`。如果 patch 指定的条目 id 不在已启动树中Loader 会发出警告并跳过。空文件或仅含注释的文件会抛出异常(其解析结果为空,而不是列表);如需禁用 overlay请使用 `[]` 或删除该文件。
- **`.env`**[`dsh-credentials-local`](../../credentials/credentials-local/README.md) 的凭据存储,只由该 provider 读取。没有任何表层会把它提升进 `process.env`:那样做会让每个已存密钥在下次运行时看起来都像只读的启动时覆盖,从而阻断从 TUI 与 Web 页面轮换密钥。环境层次由环境中的值与调用目录的 `.env` 构成(由 bin 加载;`process.loadEnvFile` 从不覆盖已有值),没有凭据 provider 的组合仍然只从这两者解析密钥
- **`config.yaml`**:在发布的默认配置上应用 Loader overlay patch语义与 include 条目的 `patches` 相同(以仓库提交的 Code Mode overlay 为模板):按 id 定位的 patch 会替换对应条目的整个 `config`(未改字段也要重述),`insert` 会添加条目,`!!js` 表达式则在挂载时插值。如果 patch 指定的条目 id 不在已启动树中Loader 会发出警告并跳过。空文件或仅含注释的文件会抛出异常(其解析结果为空,而不是列表);如需禁用 overlay请使用 `[]` 或删除该文件。
子进程测试 launcher 会把 `DSH_HOME` 指向逐测试隔离的目录,确保开发者的个人 overlay 不会泄漏到 fixture测试前置数据中。

View File

@@ -1,14 +1,17 @@
/**
* Zero-dependency atomic file replacement. `writeFileAtomic` writes a
* random-suffix sibling with exclusive create and the caller's permission
* bits, then renames it over the target, so readers observe either the old or
* the new complete content and a replaced file ends up with exactly the
* stated mode.
* Zero-dependency atomic file replacement and writer coordination.
* `writeFileAtomic` writes a random-suffix sibling with exclusive create and
* the caller's permission bits, then renames it over the target, so readers
* observe either the old or the new complete content and a replaced file ends
* up with exactly the stated mode. `withFileLock` serializes cross-process
* writers of one file through a `wx`-created `<file>.lock` sibling, so a
* read-modify-write cycle can never resurrect a state another writer just
* replaced; readers stay lock-free because the rename commit is atomic.
* @module @deepseek-ai/dsh-atomic-write
*/
import { randomBytes } from 'node:crypto'
import { mkdir, rename, rm, writeFile } from 'node:fs/promises'
import { mkdir, rename, rm, stat, writeFile } from 'node:fs/promises'
import { dirname } from 'node:path'
/**
@@ -21,6 +24,12 @@ export interface WriteFileAtomicOptions {
* rename (subject to the process umask, like every fresh inode).
*/
mode: number
/**
* Permission bits for parent directories this call creates (subject to the
* umask; existing directories keep their mode). Omission uses the mkdir
* default — pass `0o700` when the tree holds user-private data.
*/
dirMode?: number
}
/**
@@ -38,7 +47,10 @@ export interface WriteFileAtomicOptions {
* @param options - permission bits for the replacement inode.
*/
export async function writeFileAtomic(filename: string, content: string, options: WriteFileAtomicOptions): Promise<void> {
await mkdir(dirname(filename), { recursive: true })
await mkdir(dirname(filename), {
recursive: true,
...options.dirMode === undefined ? {} : { mode: options.dirMode },
})
const temp = `${filename}.${randomBytes(6).toString('hex')}.tmp`
try {
await writeFile(temp, content, { mode: options.mode, flag: 'wx' })
@@ -48,3 +60,94 @@ export async function writeFileAtomic(filename: string, content: string, options
throw error
}
}
/** Whether an exclusive create failed because the path already exists. */
function isEEXIST(error: unknown): boolean {
return (error as NodeJS.ErrnoException | null)?.code === 'EEXIST'
}
/** Whether a filesystem error means absence. */
function isENOENT(error: unknown): boolean {
return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT'
}
/**
* Writer-lock protocol constants. These are robustness invariants of the
* cross-process write protocol, not deployment tunables: a holder rewrites one
* small file in milliseconds, so contention resolves well inside the retry
* deadline, and a lock older than the stale age can only belong to a crashed
* holder.
*/
const LOCK_RETRY_INITIAL_MS = 20
const LOCK_RETRY_MAX_MS = 200
const LOCK_TIMEOUT_MS = 2_000
const LOCK_STALE_MS = 5_000
/** Options for {@link withFileLock}. */
export interface WithFileLockOptions {
/**
* Called once each time a stale (crashed-holder) lock is broken, so the
* caller can log the takeover in its own voice.
*/
onStaleBreak?: (lockPath: string) => void
}
/** Age of the lock file, or `undefined` when it vanished after a failed create. */
async function lockAgeMs(lockPath: string): Promise<number | undefined> {
try {
return Date.now() - (await stat(lockPath)).mtimeMs
} catch (error) {
if (!isENOENT(error)) throw error
return undefined
}
}
/**
* Hold the cross-process writer lock for `filename` around one operation. The
* lock is a `wx`-created sibling (`<filename>.lock`); paired with the
* rename-based commit of {@link writeFileAtomic}, readers stay lock-free and
* only writers contend. Contention backs off exponentially; a lock older than
* the stale age is a crashed holder and is broken (see
* {@link WithFileLockOptions.onStaleBreak}); a live holder past the deadline
* fails the operation with a timed-out error. The parent directory must exist.
* @param filename - the file whose writers this lock serializes.
* @param operation - the read-render-commit cycle to run while holding the lock.
* @param options - stale-takeover notification hook.
* @returns the operation's result; the lock releases on both outcomes.
*/
export async function withFileLock<T>(
filename: string,
operation: () => Promise<T>,
options?: WithFileLockOptions,
): Promise<T> {
const lockPath = `${filename}.lock`
const deadline = Date.now() + LOCK_TIMEOUT_MS
let delay = LOCK_RETRY_INITIAL_MS
for (;;) {
try {
await writeFile(lockPath, `${process.pid}\n`, { mode: 0o600, flag: 'wx' })
break
} catch (error) {
if (!isEEXIST(error)) throw error
}
const ageMs = await lockAgeMs(lockPath)
// The holder released between the failed create and the stat: the lock is
// free right now, so retry without burning backoff or deadline.
if (ageMs === undefined) continue
if (ageMs > LOCK_STALE_MS) {
options?.onStaleBreak?.(lockPath)
await rm(lockPath, { force: true })
continue
}
if (Date.now() >= deadline) {
throw new Error(`atomic-write: timed out waiting for the writer lock at ${lockPath}`)
}
await new Promise(resolve => setTimeout(resolve, delay))
delay = Math.min(delay * 2, LOCK_RETRY_MAX_MS)
}
try {
return await operation()
} finally {
await rm(lockPath, { force: true })
}
}

View File

@@ -33,6 +33,7 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
MessageId: 'core.md',
HookContext: 'core.md',
SettleReason: 'core.md',
AdapterRegistrationHandle: 'core.md',
LlmCallConfig: 'core.md',
LlmModelContext: 'core.md',
LlmModelReasoningInfo: 'core.md',

View File

@@ -31,6 +31,11 @@
"symbol": "FinishReasonMap",
"source": "packages/llm/llm/src/types.ts"
},
{
"doc": "docs/core-data-structures/core.md",
"symbol": "AdapterRegistrationHandle",
"source": "packages/llm/llm/src/index.ts"
},
{
"doc": "docs/core-data-structures/core.md",
"symbol": "LlmProviderInfo",