diff --git a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.i18n.yaml new file mode 100644 index 0000000000..c7861321a0 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.i18n.yaml @@ -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-29-request-level-llm-config-credentials.md +2026-07-29-request-level-llm-config-credentials.md: f12a2496a767decc3ce2b065f6be03009aec8992 +2026-07-29-request-level-llm-config-credentials.zh.md: 99fd90013a24746962ca02a5f4f18cdccd53f71a diff --git a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md new file mode 100644 index 0000000000..f12a2496a7 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md @@ -0,0 +1,29 @@ +# Agent Note: request-level LLM configuration and the credential seam + +Status: implemented + +English | [中文](2026-07-29-request-level-llm-config-credentials.zh.md) + +> Scope: the first production consumers of `ctx.settings` (the two LLM adapter plugins), the new `packages/credentials/` capability family, and the `packages/util/atomic-write` extraction. The follow-up wire surface (`settings.*`/`credentials.*` RPC, secret-role masking, the web settings form) is a separate PR and not part of this note's shipped scope. + +## Problem + +The [settings seam](2026-07-28-user-settings-seam.md) shipped without a production consumer, and the LLM adapters were the motivating one: both froze `apiKey`/`baseURL`/catalog into adapter instances at plugin load, so a changed key or endpoint needed a process restart, and a missing key failed plugin load — the worst possible first-run posture for a personal config page ("store a key, then restart"). Secrets were also headed the wrong way: the natural move (put `apiKey` in the settings document) would have forced masking, server-side backfill on `replace`, and dotfiles-sync warnings, a mitigation stack for a problem peer products simply do not have — Codex (`env_key` + auth.json), Reasonix (`api_key_env` + home `.env`), OpenCode/Pi (`auth.json`), Claude Code (`apiKeyHelper`) all keep secrets out of configuration files. + +## Decision + +**Per-request resolution, not fiber rebuilds.** The adapters take an options thunk (and a per-stream credential resolver) instead of frozen construction facts, resolving once per operation — the Pi pattern, with its tested semantics: two requests straddling a change see two configurations, one request resolves exactly once, and an in-flight stream keeps the facts it started with. This deletes the entire swap machinery a rebuild design needs (`DUPLICATE_ADAPTER` ordering, `NO_ADAPTER` windows, a deferred-activation state machine) and makes a missing key a *request-time* actionable failure (`MISSING_CREDENTIAL` naming every entry point) while the route stays registered and the catalog stays browsable. The one registration-captured fact — the retry policy the `ctx.llm` registry snapshots at `registerAdapter` (plus pi-ai's route *set*) — re-registers the same adapter instance in one synchronous section when it changes. + +**Secrets are references, values live behind `ctx.credentials`.** Configuration (both planes) carries `apiKeyEnv: DEEPSEEK_API_KEY`; the three-package credential seam resolves it per operation. `credentials-local` layers the live process environment (read-only, wins — a launch-time override is operator intent and must be *visibly* read-only, so shadowed writes reject instead of appearing to succeed) over `$DSH_HOME/.env` (writable, byte-preserving line edits, a quoting ladder dotenv reads back verbatim, wholesale snapshot replacement on reload so a deleted entry never lingers — the Claude Code additive-reapply lesson). Resolution order in the adapters is literal `apiKey` first (preserving the historical `config.apiKey ?? env` observable semantics), then the seam, then — only without a mounted seam — the raw environment variable. + +**Per-plugin namespaces, schema ≡ `Config`.** Each adapter registers its own namespace (`llm-deepseek`, `llm-pi-ai`) with its plugin `Config` schema and its `cordis.yml` entry as the composition `base` — a settings section is the same YAML shape as the entry config, and `resolveAdapterOptions`/`resolveProfiles` stay the one explicit resolve step for both. A live snapshot failing a beyond-schema bound keeps the last good facts (the seam's last-good philosophy extended one level up); the entry config itself still fails load. pi-ai's `providers` became a dict keyed by route so base and user layers merge per provider and the route set is structural; the array shape fails loud with migration directions, and an empty dict is the valid dormant posture — a composition ships the adapter bare and every route stays a user-plane decision. + +## Alternatives considered + +- **A bridge plugin (`dsh-llm-models`) owning one unified `models` dict** — with per-plugin namespaces there is nothing left to bridge, and the adapter-mapping rules it needed were pure invented indirection. +- **Secrets in settings.yaml under `role('secret')` masking** — deleting the problem (references) beats mitigating it (mask + backfill + sync warnings); the coding-agent cohort is unanimous. +- **Registry-level live retry policy** — making `providerRetryPolicy` re-read per call would silently change the `ctx.llm` capture contract every registration relies on; re-registering the route in place keeps that contract and stays observable. + +## 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). 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)). diff --git a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.zh.md b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.zh.md new file mode 100644 index 0000000000..99fd90013a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.zh.md @@ -0,0 +1,29 @@ +# Agent Note:请求级 LLM 配置与凭据 seam + +Status: implemented + +[English](2026-07-29-request-level-llm-config-credentials.md) | 中文 + +> 范围:`ctx.settings` 的第一批生产消费方(两个 LLM 适配器插件)、新增的 `packages/credentials/` 能力族,以及 `packages/util/atomic-write` 的抽取。后续的 wire 面(`settings.*`/`credentials.*` RPC、secret 角色脱敏、web 设置表单)是单独的 PR,不在本 note 已交付范围内。 + +## 问题 + +[settings seam](2026-07-28-user-settings-seam.md) 落地时没有生产消费方,而 LLM 适配器正是当初驱动该 seam 的那个消费方:两个适配器都在插件加载时把 `apiKey`/`baseURL`/catalog 冻结进适配器实例,改密钥或端点就要重启进程,密钥缺失则直接使插件加载失败——对个人配置页而言,这是最糟糕的首次运行姿态(「先存密钥,再重启」)。机密的走向也不对:顺理成章的做法(把 `apiKey` 放进设置文档)会被迫引入脱敏、`replace` 时的服务端回填与 dotfiles 同步告警,为一个同类产品根本没有的问题堆起一整摞缓解措施——Codex(`env_key` + auth.json)、Reasonix(`api_key_env` + 家目录 `.env`)、OpenCode/Pi(`auth.json`)、Claude Code(`apiKeyHelper`)全都把机密挡在配置文件之外。 + +## 决策 + +**按请求解析,而非重建 fiber。**适配器改为接收一个 options thunk(外加按流调用的凭据解析器),不再持有冻结的构造期事实,每个操作解析一次——即 Pi 的模式,连同其经测试固定的语义:跨越一次变更的两个请求看到两份配置,一个请求恰好解析一次,进行中的流保持其起始事实。这删掉了重建式设计所需的整套切换机制(`DUPLICATE_ADAPTER` 顺序问题、`NO_ADAPTER` 窗口、延迟激活状态机),并把密钥缺失变成*请求时*可行动的失败(`MISSING_CREDENTIAL` 点名每个配置入口),同时路由保持注册、catalog 保持可浏览。唯一在注册期捕获的事实——`ctx.llm` 注册表在 `registerAdapter` 时快照的重试策略(外加 pi-ai 的路由*集合*)——在其变化时于一个同步区段内原地重新注册同一适配器实例。 + +**机密是引用,值藏在 `ctx.credentials` 背后。**配置(两个面)携带 `apiKeyEnv: DEEPSEEK_API_KEY`;三包凭据 seam 按操作解析它。`credentials-local` 把活跃进程环境(只读、优先——启动时覆盖是操作者意图,必须*可见地*只读,因此被遮蔽的写入直接拒绝而不是表面成功)叠加在 `$DSH_HOME/.env` 之上(可写、保字节行级编辑、dotenv 能逐字读回的引号阶梯、重载时整体替换快照使删除的条目绝不滞留——来自 Claude Code 增量重放(additive reapply)的教训)。适配器内的解析顺序为:字面 `apiKey` 优先(保留历史 `config.apiKey ?? env` 的可观察语义),然后是 seam,最后——仅在未挂载 seam 时——原始环境变量。 + +**按插件划分 namespace,schema ≡ `Config`。**每个适配器注册自己的 namespace(`llm-deepseek`、`llm-pi-ai`),schema 用其插件 `Config` schema,组合 `base` 用其 `cordis.yml` 条目——settings 分节与 entry 配置是同一种 YAML 形状,`resolveAdapterOptions`/`resolveProfiles` 对两者仍是唯一的显式 resolve 步骤。存活快照若违反 schema 之外的约束,则保留最后可用事实(seam 的最后可用值哲学向上延伸一层);entry 配置本身仍会加载失败。pi-ai 的 `providers` 改为以路由为键的字典,base 层与用户层因此按提供方合并,路由集合也由结构直接表达;数组形状响亮失败并给出迁移指引,而空字典是合法的休眠姿态——组合可以裸挂该适配器,把每一条路由都留给用户面决定。 + +## 曾考虑的替代方案 + +- **由桥接插件(`dsh-llm-models`)持有统一的 `models` 字典**——有了按插件划分的 namespace,就没有什么可桥接的了;它所需的适配器映射规则纯属凭空发明的间接层。 +- **把机密放进 settings.yaml 并靠 `role('secret')` 脱敏**——删除问题本身(引用)胜过缓解问题(脱敏 + 回填 + 同步告警);编码 agent 同类产品在这一点上口径一致。 +- **注册表级的实时重试策略**——让 `providerRetryPolicy` 每次调用都重读,会静默改变所有注册都依赖的 `ctx.llm` 捕获契约;原地重新注册路由既保住该契约,又保持可观察。 + +## 后果 + +上手流程端到端免重启(由 `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))。 diff --git a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.i18n.yaml new file mode 100644 index 0000000000..98f2b0cb0d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md new file mode 100644 index 0000000000..6fe5f554ac --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md @@ -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). diff --git a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.zh.md b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.zh.md new file mode 100644 index 0000000000..3eb3b02206 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.zh.md @@ -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 钥匙串凭据提供方,以及针对两个写方编辑同一引用的逐值修订号检查(后写胜出仍是成文的解决方式)。 diff --git a/AGENTS.md b/AGENTS.md index cce8df3137..9667432285 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,6 +32,7 @@ packages/ @deepseek-ai/dsh- workspaces at packages/// hooks/ Claude Code/Codex hook bridges + shared wire-protocol library session-persistence/ persistence seam + JSONL/SQLite backends settings/ user-settings seam + file-backed provider + credentials/ credential-reference seam + env-over-.env provider acp/ automation-only Agent Client Protocol server ui/ TUI/JSON-RPC bridges; boot, approval, interaction plugins examples/ demo bundles (agent-spine + TUI/CLI/ACP/JSON-RPC bins) leaves load diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index 7e4aacef29..256587556f 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/README.i18n.yaml @@ -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: 2bc36cce6205a4bfc3ba1d7ee15f0e0b2feab215 -README.zh.md: 0e0771658cecb4bee0f3eadd0639ad531e64ea3c +README.md: d783d75cc9747d13887386fcf7609a6778e5dfb5 +README.zh.md: 3f5ce7e7a3a302fd9e255c1042ccb7b03deb59d9 diff --git a/apps/cli/README.md b/apps/cli/README.md index 2bc36cce62..d783d75cc9 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -11,9 +11,9 @@ The TUI surface: - resumes a persisted session with `dsh --resume ` 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 resume invocation; runtimes without process replacement leave the session running and say so. This CLI owns session identity and the exit line rather than the config: it mints or selects the `main` session id and provides it, plus the exact command that reproduces this invocation, on the boot context ([`MAIN_SESSION_ID_KEY`](../../packages/ui/tui/README.md) and `TUI_GOODBYE_MESSAGE_KEY`). No `cordis.yml` key can drop resume, 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 (`dsh meta` is the sole exception, below); - 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`. -`dsh meta` is that same TUI with this harness checkout as the workspace, so working on dsh itself needs no `cd`. It chdirs to the checkout root — resolved from the launcher's real path, the same root the source-path prompt section names — after both `.env` layers are loaded, so environment precedence is unchanged while the session cwd and HMR watch root move together. Meta always starts a fresh session and accepts no default-surface options; use ordinary `dsh --resume ` to resume a persisted session. +`dsh meta` is that same TUI with this harness checkout as the workspace, so working on dsh itself needs no `cd`. It chdirs to the checkout root — resolved from the launcher's real path, the same root the source-path prompt section names — after the environment is settled, so precedence is unchanged while the session cwd and HMR watch root move together. Meta always starts a fresh session and accepts no default-surface options; use ordinary `dsh --resume ` to resume a persisted session. `dsh upgrade` is a guided fresh-session entry over the default TUI surface: it mints a fresh session in the invoking directory and seeds its first turn with the bundled `dsh-upgrade` skill, exactly as if the user typed `/skill:`. The launcher passes the skill name on the boot context ([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)), which the TUI auto-invokes once the chat is live. Both take no options — `--config`, `-p`, and `--resume` fail loud — and seed only on this first launch, so a later `dsh --resume ` of the session is an ordinary TUI session with no re-injection. diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 0e0771658c..3f5ce7e7a3 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -11,9 +11,9 @@ TUI 界面: - 使用 `dsh --resume ` 恢复已持久化会话。当 Node 宿主公开 `process.execve` 时,还会提供 TUI 的原地移交宿主:选择器预检并刷新当前会话后,宿主会释放应用,并以规范化的恢复调用替换进程;不支持进程替换的运行时会让会话继续运行并给出提示。会话身份与退出行由本 CLI 拥有,而非由配置指定:它创建或选定 `main` 会话 id,并把该 id 以及可复现本次调用的确切命令一起提供到启动上下文([`MAIN_SESSION_ID_KEY`](../../packages/ui/tui/README.md) 与 `TUI_GOODBYE_MESSAGE_KEY`)。任何 `cordis.yml` 键都无法移除恢复能力;缺失或无法读取的 id 会明确报错,而不会创建新会话; - 将 **调用目录** 视为 workspace:会话、相对路径和 workspace 指令都从 cwd 解析(`dsh meta` 是唯一例外,见下文); - 告知 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`。 -`dsh meta` 是以本 harness checkout 为 workspace 的同一个 TUI,因此开发 dsh 自身无需 `cd`。它在两层 `.env` 都加载之后才 chdir 到 checkout 根目录(从启动器的真实路径解析,与源码路径提示词段所指的根目录相同),因此环境优先级不变,而会话 cwd 与 HMR 监视根目录会一并移动。Meta 始终创建新会话,不接受默认界面的任何选项;恢复已持久化会话应使用普通的 `dsh --resume `。 +`dsh meta` 是以本 harness checkout 为 workspace 的同一个 TUI,因此开发 dsh 自身无需 `cd`。它在环境确定之后才 chdir 到 checkout 根目录(从启动器的真实路径解析,与源码路径提示词段所指的根目录相同),因此环境优先级不变,而会话 cwd 与 HMR 监视根目录会一并移动。Meta 始终创建新会话,不接受默认界面的任何选项;恢复已持久化会话应使用普通的 `dsh --resume `。 `dsh upgrade` 是默认 TUI 界面之上的引导式全新会话入口:它在调用目录中创建一个全新会话,并以内置 `dsh-upgrade` skill 播种其首轮,效果等同于用户手动键入 `/skill:`。启动器将 skill 名称提供到启动上下文([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)),TUI 在聊天就绪后自动调用它。两者都不接受任何选项——`--config`、`-p`、`--resume` 都会明确报错——且仅在首次启动时播种,因此之后 `dsh --resume ` 恢复该会话时是普通 TUI 会话,不会重复注入。 diff --git a/apps/cli/composition.md b/apps/cli/composition.md index 7082329d6d..2e71c6c07b 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -28,6 +28,10 @@ flowchart LR cfg --> plugin_tui_tasks plugin_tui_llm_retry["llm-retry
@deepseek-ai/dsh-llm-retry"] cfg --> plugin_tui_llm_retry + plugin_tui_settings["settings
@deepseek-ai/dsh-settings-local"] + cfg --> plugin_tui_settings + plugin_tui_credentials["credentials
@deepseek-ai/dsh-credentials-local"] + cfg --> plugin_tui_credentials plugin_tui_llm_pi_ai["llm-pi-ai
@deepseek-ai/dsh-llm-pi-ai"] cfg --> plugin_tui_llm_pi_ai plugin_tui_session_persistence_jsonl["session-persistence-jsonl
@deepseek-ai/dsh-session-persistence-jsonl"] @@ -114,6 +118,8 @@ flowchart LR | `agent` | `@deepseek-ai/dsh-agent` | | `tasks` | `@deepseek-ai/dsh-tasks-local` | | `llm-retry` | `@deepseek-ai/dsh-llm-retry` | +| `settings` | `@deepseek-ai/dsh-settings-local` | +| `credentials` | `@deepseek-ai/dsh-credentials-local` | | `llm-pi-ai` | `@deepseek-ai/dsh-llm-pi-ai` | | `session-persistence-jsonl` | `@deepseek-ai/dsh-session-persistence-jsonl` | | `session-query-sqlite` | `@deepseek-ai/dsh-session-query-sqlite` | diff --git a/apps/cli/config/base.cordis.yml b/apps/cli/config/base.cordis.yml index bc8c3434de..e885ce6ef1 100644 --- a/apps/cli/config/base.cordis.yml +++ b/apps/cli/config/base.cordis.yml @@ -56,16 +56,28 @@ - id: llm-retry name: '@deepseek-ai/dsh-llm-retry' +# User-settings document (`$DSH_HOME/settings.yaml`, hot-reloaded): a +# `llm-deepseek:` or `llm-pi-ai:` section there overrides the adapter entries +# below without a restart, and is what the web Models page writes. +- id: settings + name: '@deepseek-ai/dsh-settings-local' + +# Credential store: the live process environment over `$DSH_HOME/.env` +# (owner-only file, hot-reloaded). Adapters resolve their key references +# through it at each request, so no key is inlined in this file — and nothing +# hoists that document into the process environment, which would make every +# stored key read as an unrotatable ambient override. +- id: credentials + name: '@deepseek-ai/dsh-credentials-local' + +# The pi-ai multi-provider twin, mounted dormant: zero routes (and no extra +# models in the picker) until a `llm-pi-ai:` settings section supplies provider +# profiles — then those routes register live, keys resolving per request +# through their apiKeyEnv references, and drop again when the section empties. +# Which adapters exist is composition; which providers run is the user's +# settings document. - id: llm-pi-ai name: '@deepseek-ai/dsh-llm-pi-ai' - config: - providers: - - provider: openai - apiKey: !!js process.env.OPENAI_API_KEY - baseURL: !!js process.env.OPENAI_BASE_URL - - provider: anthropic - apiKey: !!js process.env.ANTHROPIC_API_KEY - baseURL: !!js process.env.ANTHROPIC_BASE_URL - id: session-persistence-jsonl name: '@deepseek-ai/dsh-session-persistence-jsonl' @@ -223,10 +235,9 @@ - id: fs-local name: '@deepseek-ai/dsh-fs-local' -# The native DeepSeek adapter; reads the key/base-url the boot's layered .env -# loading left in the environment. Thinking defaults are a surface choice. +# The native DeepSeek adapter. No key or endpoint is inlined: both resolve per +# request from the `llm-deepseek:` settings section over this entry, with the +# key coming from the credential store below. Thinking defaults are a surface +# choice. - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' - config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_BASE_URL diff --git a/apps/cli/package.json b/apps/cli/package.json index f1db776206..94cf6da9c2 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -51,6 +51,7 @@ "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-compact-basic": "workspace:^", "@deepseek-ai/dsh-compact-tool-result-prune": "workspace:^", + "@deepseek-ai/dsh-credentials-local": "workspace:^", "@deepseek-ai/dsh-frontend": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-fs-policy": "workspace:^", @@ -73,7 +74,6 @@ "@deepseek-ai/dsh-sandbox-local": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", - "@deepseek-ai/dsh-tool-cordis": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", @@ -83,6 +83,7 @@ "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-session-title-first-message-llm": "workspace:^", + "@deepseek-ai/dsh-settings-local": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-skill-local": "workspace:^", "@deepseek-ai/dsh-spill-local": "workspace:^", @@ -100,6 +101,7 @@ "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-tool-ask-user": "workspace:^", "@deepseek-ai/dsh-tool-bash": "workspace:^", + "@deepseek-ai/dsh-tool-cordis": "workspace:^", "@deepseek-ai/dsh-tool-fs": "workspace:^", "@deepseek-ai/dsh-tool-fs-search": "workspace:^", "@deepseek-ai/dsh-tool-goal": "workspace:^", diff --git a/apps/cli/src/app-cli-entry.ts b/apps/cli/src/app-cli-entry.ts index 1ba1c0fc1e..f18399e27b 100644 --- a/apps/cli/src/app-cli-entry.ts +++ b/apps/cli/src/app-cli-entry.ts @@ -1,10 +1,12 @@ /** * AppCLIEntry — the pre-cordis boot glue the config-tree dsh surfaces share * for the Web/headless surface. - * Everything here is what must exist before the Loader runs: layered env, - * the patch composition over the shipped base and surface overlay (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 base and surface overlay (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 and is never hoisted here. */ import { readFileSync } from 'node:fs' @@ -14,8 +16,7 @@ import { join, resolve } from 'node:path' import { Context } from 'cordis' import type { PatchOptions } from '@cordisjs/plugin-include' import yaml from 'js-yaml' -import { boot, installFailLoud, loadEnv, loadOverlayPatches, loadPersonalPatches } from '@deepseek-ai/dsh-app-boot' -import { resolveDshHome } from '@deepseek-ai/dsh-paths' +import { boot, installFailLoud, loadOverlayPatches, loadPersonalPatches } from '@deepseek-ai/dsh-app-boot' // Empty type import carries the httpServer Context merge for the port read below. import type {} from '@deepseek-ai/dsh-host-webserver' @@ -144,12 +145,11 @@ export class AppCLIEntry { constructor(private readonly options: AppCLIEntryOptions) {} /** - * Run the boot chain: layered env → patch composition → Loader include - * boot (dev row before await) → fail-loud triple. + * Run the boot chain: patch composition → Loader include boot (dev row + * before await) → fail-loud triple. * @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() @@ -159,11 +159,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 profile json, CLI flags, and the resolved * frontend dist. Patches replace a row's config wholesale, so each patched row's yml diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index f13451c04e..3469a737c1 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -26,12 +26,10 @@ import { addHarnessSourceSection, boot, installFailLoud, - loadEnv, loadOverlayPatches, loadPersonalPatches, resolveConfigPath, } from '@deepseek-ai/dsh-app-boot' -import { resolveDshHome } from '@deepseek-ai/dsh-paths' import { SessionId } from '@deepseek-ai/dsh-session' import { SESSION_QUERY_SQLITE_PATH_KEY } from '@deepseek-ai/dsh-session-query-sqlite' import { CONFIGURED_AGENT_IDENTITIES_KEY } from '@deepseek-ai/dsh-agent-loop' @@ -124,11 +122,12 @@ export async function runTui( 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()) - // Both .env layers are loaded, so switching the workspace here cannot alter - // environment precedence. The cwd IS the workspace seam: the shipped config + // The bin already loaded the invoking directory's .env, and that is the + // whole environment: $DSH_HOME/.env is credentials-local's writable store, + // and hoisting it would make every stored key read as a read-only ambient + // override on the next run — unrotatable from the TUI or the web page. + // The environment is settled, so switching the workspace here cannot alter + // its precedence. The cwd IS the workspace seam: the shipped config // resolves the session cwd and the HMR watch root from it, so one chdir moves // both together. Sessions themselves live under the Harness home so `/resume` // spans every workspace, and are unaffected by this chdir. diff --git a/apps/cli/tests/tui-keyless-smoke.e2e.ts b/apps/cli/tests/tui-keyless-smoke.e2e.ts index 59042892e1..714a838124 100644 --- a/apps/cli/tests/tui-keyless-smoke.e2e.ts +++ b/apps/cli/tests/tui-keyless-smoke.e2e.ts @@ -353,34 +353,40 @@ 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` row — a row the SURFACE - // OVERLAY inserted, not one the base declares — with a `!!js` reference to - // it, and the banner renders the patched welcome verbatim. That proves a - // later patch list reaches a row an earlier one inserted. + it('applies the personal overlay: config.yaml patches an overlay-inserted row, 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. config.yaml patches the `tui` row — a row the + // SURFACE OVERLAY inserted, not one the base declares — proving a later + // patch list reaches a row an earlier one inserted. The single `!!js` + // expression prefers the PERSONAL variable, so the welcome can only render + // the project value while 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: workspace-context', ' disabled: true', '- id: tui', ' config:', " sessionId: !!js configuredAgentIdentities?.main?.id ?? 'main'", - ' 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) diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index dfff50708f..d7054b8b3c 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -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/architecture.md -architecture.md: 1fd9bd128d1bcc0dd91d46131981ea4fc331bd74 -architecture.zh.md: 8521f09c6e415f9f8d1c0a44f7534b59c876decc +architecture.md: bfea67b9f83958e16b58e63b99e326349f6eff15 +architecture.zh.md: c2fd6cdd84ad2f6435faebffa0c4c1a6da0ade96 diff --git a/docs/architecture.md b/docs/architecture.md index 1fd9bd128d..bfea67b9f8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -46,6 +46,8 @@ Harnesses are [Cordis](cordis-primer.md) contexts; packages contribute services, | `ctx.sessionPersistence` | [`session-persistence/`](../packages/session-persistence/README.md) | durable session-log storage | | `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | live-preferred exact/filter/trace queries over SQLite FTS, workspace-authorized model tools | | `ctx.sessionTitle` | [`session-title/`](../packages/session-title/README.md) | log-backed fallbacks, one optional asynchronous provider | +| `ctx.settings` | [`settings/`](../packages/settings/README.md) | per-plugin user-settings namespaces layered over composition entries | +| `ctx.credentials` | [`credentials/`](../packages/credentials/README.md) | named secret references resolved per operation, never inlined in configuration | | `ctx.directoryPicker` | [`host/directory-picker`](../packages/host/directory-picker/README.md) | GUI-host directory picking (`native`/`browse` interactions) | | `ctx.typert` | [`typert/registry`](../packages/typert/registry/README.md) | runtime registry for generated package reflection and live Zod schemas | | `ctx.invariants` | [`support/invariants`](../packages/support/invariants/README.md) | package-name-selected registry of package-owned runtime checks | diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 8521f09c6e..c2fd6cdd84 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -46,6 +46,8 @@ | `ctx.sessionPersistence` | [`session-persistence/`](../packages/session-persistence/README.md) | 会话日志的持久化存储 | | `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | 基于 SQLite 全文搜索的实时优先精确检索/过滤/追踪、经工作区授权的模型工具 | | `ctx.sessionTitle` | [`session-title/`](../packages/session-title/README.md) | 基于日志的回退标题和单个可选异步提供方 | +| `ctx.settings` | [`settings/`](../packages/settings/README.md) | 按插件划分的用户设置命名空间,分层叠加在装配条目之上 | +| `ctx.credentials` | [`credentials/`](../packages/credentials/README.md) | 具名密钥引用,按操作解析,绝不内联进配置 | | `ctx.directoryPicker` | [`host/directory-picker`](../packages/host/directory-picker/README.md) | GUI 宿主目录选取(`native`/`browse` 交互) | | `ctx.typert` | [`typert/registry`](../packages/typert/registry/README.md) | 生成的包反射和实时 Zod schema 的运行时注册表 | | `ctx.invariants` | [`support/invariants`](../packages/support/invariants/README.md) | 按包名筛选包自有运行时检查的注册表 | diff --git a/docs/capability-seams.md b/docs/capability-seams.md index e53dd67aba..69fa5cfef7 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -41,6 +41,9 @@ flowchart LR pkg_settings["settings"] svc_settings["ctx.settings
User-settings seam"] pkg_settings_local["settings-local"] + pkg_credentials["credentials"] + svc_credentials["ctx.credentials
Credential seam"] + pkg_credentials_local["credentials-local"] pkg_session_telemetry["session-telemetry"] svc_telemetry["ctx.telemetry
Session telemetry seam"] pkg_session_telemetry_otel["session-telemetry-otel"] @@ -174,6 +177,8 @@ flowchart LR pkg_compact --> svc_compact pkg_compact_basic --> svc_compact pkg_compact_tool_result_prune --> svc_toolResultPrune + pkg_credentials --> svc_credentials + pkg_credentials_local --> svc_credentials pkg_directory_picker --> svc_directoryPicker pkg_directory_picker_browse --> svc_directoryPicker pkg_directory_picker_native --> svc_directoryPicker @@ -258,6 +263,8 @@ flowchart LR svc_codeRuntime --> pkg_tools svc_commands --> pkg_tui svc_compact --> pkg_compact_basic + svc_credentials --> pkg_llm_deepseek + svc_credentials --> pkg_llm_pi_ai svc_directoryPicker --> pkg_apiproxy svc_fs --> pkg_tool_fs svc_httpServer --> pkg_connection @@ -296,6 +303,8 @@ flowchart LR svc_sessions --> pkg_session_query svc_sessions --> pkg_session_query_sqlite svc_sessions --> pkg_subagent_inprocess + svc_settings --> pkg_llm_deepseek + svc_settings --> pkg_llm_pi_ai svc_skills --> pkg_tool_skill svc_spillStore --> pkg_spill_policy svc_storage --> pkg_storage_domain @@ -346,7 +355,8 @@ flowchart LR | `ctx.invariants` | `core` | [`invariants`](../packages/support/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures. | | `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader) | - | Plugins register live zod contributions directly or through dsh-typert-loader; runtime consumers query schemas and reflection metadata at their own edges. | | `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. | -| `ctx.settings` | `seam` | [`settings`](../packages/settings/settings) | [`settings-local`](../packages/settings/settings-local) | - | - | Plugins register namespace schemas and resolve layered values; providers store the raw document. No production consumer is migrated yet. | +| `ctx.settings` | `seam` | [`settings`](../packages/settings/settings) | [`settings-local`](../packages/settings/settings-local) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | Plugins register namespace schemas and resolve layered values; providers store the raw document. The LLM adapters register their entry config as the composition base under the user section. | +| `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | Configuration carries references to secrets; providers own the values. Consumers resolve per operation, so a rotated credential reaches the very next request. | | `ctx.telemetry` | `seam` | [`session-telemetry`](../packages/telemetry/session-telemetry) | [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | - | - | The seam captures, redacts, and hands session records to one backend; nothing else consumes the service — its output leaves the process. | | `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | Backends register side by side under names; data forms (domain first) mount on the hub and translate typed operations into opaque KV-unit primitives. | | `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace) | - | Waits for every configured backend, then publishes the domain form as one lifecycle-bound service for typed durable state. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 0506fc62e4..159fc308fd 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -407,6 +407,24 @@ export interface ToolResultPruneConfig { Source: [`packages/compact/compact-tool-result-prune/src/types.ts:4`](../packages/compact/compact-tool-result-prune/src/types.ts) +## `@deepseek-ai/dsh-credentials-local` + +```ts config-catalog +/** Plugin config: file location and hot-reload behavior. */ +export interface Config { + /** Credentials document path; defaults to `.env` under the harness home. */ + path?: string + /** Harness home used when `path` is omitted; defaults to `$DSH_HOME` or `~/.dsh`. */ + dshHome?: string + /** Watch the document and hot-publish external edits; defaults to true. */ + watch?: boolean + /** Watcher write-settle window in milliseconds; defaults to 100. */ + debounceMs?: number +} +``` + +Source: [`packages/credentials/credentials-local/src/index.ts:26`](../packages/credentials/credentials-local/src/index.ts) + ## `@deepseek-ai/dsh-fs-local` ```ts config-catalog @@ -602,15 +620,18 @@ Requires: `llm` ```ts config-catalog /** - * Plugin config, validated by the same-named schemastery schema. Every field - * is optional in yml: credentials/endpoint fall back to the environment (a - * missing API key fails plugin load, not the first call), omitted thinking - * mode uses the provider default, and omitted reasoning effort resolves to - * `high`. + * Plugin config, validated by the same-named schemastery schema and doubling + * as the `llm-deepseek` settings-section shape. Every field is optional in + * yml: a missing API key resolves through {@link Config.apiKeyEnv} at each + * request (a request without any key fails with `MISSING_CREDENTIAL`, not at + * plugin load), omitted thinking mode uses the provider default, and omitted + * reasoning effort resolves to `high`. */ export interface Config { - /** API key; falls back to $DEEPSEEK_API_KEY. Required one way or the other. */ + /** Literal API key; prefer {@link apiKeyEnv} so no secret enters configuration files. */ apiKey?: string + /** Credential reference (environment-variable name) resolved per request; defaults to `DEEPSEEK_API_KEY`. */ + apiKeyEnv?: string /** Endpoint base; falls back to $DEEPSEEK_BASE_URL, then the public API. */ baseURL?: string /** Deployment thinking policy; `disabled` limits every conversation request to `off`. */ @@ -642,25 +663,29 @@ export interface DeepSeekCatalogModel { Depends on: [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) -Source: [`packages/llm/llm-deepseek/src/index.ts:36`](../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` Requires: `llm` ```ts config-catalog -/** Plugin configuration: the non-empty provider profiles this instance owns. */ +/** Plugin configuration: the provider routes this instance owns. */ export interface Config { - /** Non-empty set of pi-ai provider routes this adapter instance owns. */ - providers: PiAiProviderProfile[] + /** + * pi-ai provider routes, keyed by provider. An empty (or omitted) dict is + * the dormant settings-driven posture: the adapter mounts with no routes + * and registers them the moment a settings section supplies profiles. + */ + providers?: Record } -/** Configuration for one pi-ai provider route. */ +/** Configuration for one pi-ai provider route; the `providers` dict key IS the route. */ export interface PiAiProviderProfile { - /** pi-ai provider catalog name and Harness route key. */ - provider: string - /** Provider credential; when absent pi-ai uses its provider-native ambient discovery. */ + /** Literal provider credential; prefer {@link apiKeyEnv}. With both absent pi-ai uses its provider-native ambient discovery. */ apiKey?: string + /** Credential reference (environment-variable name) resolved per request through `ctx.credentials`. */ + apiKeyEnv?: string /** Override the selected catalog model's endpoint without changing its protocol metadata. */ baseURL?: string /** Provider request headers; Harness attribution wins reserved names. */ @@ -686,7 +711,7 @@ export interface PiAiProviderProfile { Depends on: `CacheRetention` (`@earendil-works/pi-ai`) · `ModelThinkingLevel` (`@earendil-works/pi-ai`) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · `ThinkingBudgets` (`@earendil-works/pi-ai`) · `Transport` (`@earendil-works/pi-ai`) -Source: [`packages/llm/llm-pi-ai/src/config.ts:54`](../packages/llm/llm-pi-ai/src/config.ts) +Source: [`packages/llm/llm-pi-ai/src/config.ts:62`](../packages/llm/llm-pi-ai/src/config.ts) ## `@deepseek-ai/dsh-llm-replay` @@ -2298,6 +2323,7 @@ Abstract service classes — a deployment loads a concrete implementation packag - `@deepseek-ai/dsh-bash` — abstract `BashExecutor` ([`packages/bash/bash/src/index.ts`](../packages/bash/bash/src/index.ts)) - `@deepseek-ai/dsh-code-runtime` — abstract `CodeRuntime` ([`packages/code-runtime/code-runtime/src/index.ts`](../packages/code-runtime/code-runtime/src/index.ts)) - `@deepseek-ai/dsh-compact` — abstract `CompactService` ([`packages/compact/compact/src/index.ts`](../packages/compact/compact/src/index.ts)) +- `@deepseek-ai/dsh-credentials` — abstract `Credentials` ([`packages/credentials/credentials/src/index.ts`](../packages/credentials/credentials/src/index.ts)) - `@deepseek-ai/dsh-fs` — abstract `FileSystem` ([`packages/fs/fs/src/index.ts`](../packages/fs/fs/src/index.ts)) - `@deepseek-ai/dsh-host-directory-picker` — abstract `DirectoryPicker` ([`packages/host/directory-picker/src/index.ts`](../packages/host/directory-picker/src/index.ts)) - `@deepseek-ai/dsh-sandbox` — abstract `SandboxProvider` ([`packages/sandbox/sandbox/src/index.ts`](../packages/sandbox/sandbox/src/index.ts)) @@ -2317,6 +2343,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-acp-snapshot` ([`packages/support/acp-snapshot/src/index.ts`](../packages/support/acp-snapshot/src/index.ts)) - `@deepseek-ai/dsh-agent-loop-testkit` ([`packages/support/agent-loop-testkit/src/index.ts`](../packages/support/agent-loop-testkit/src/index.ts)) - `@deepseek-ai/dsh-app-boot` ([`packages/ui/app-boot/src/index.ts`](../packages/ui/app-boot/src/index.ts)) +- `@deepseek-ai/dsh-atomic-write` ([`packages/util/atomic-write/src/index.ts`](../packages/util/atomic-write/src/index.ts)) - `@deepseek-ai/dsh-brand` ([`packages/util/brand/src/index.ts`](../packages/util/brand/src/index.ts)) - `@deepseek-ai/dsh-client-test-runtime` ([`packages/client/test-runtime/src/index.ts`](../packages/client/test-runtime/src/index.ts)) - `@deepseek-ai/dsh-client-ui-primitives` ([`packages/client/ui-primitives/src/index.ts`](../packages/client/ui-primitives/src/index.ts)) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 9d48809dd0..249d26b416 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -439,6 +439,32 @@ A command was registered or unregistered. This is an unfiltered registry notific Source: [`packages/ui/commands/src/index.ts:154`](../../packages/ui/commands/src/index.ts) +## `credentials/*` + +### `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. 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. 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 + */ +'credentials/updated'(ref: CredentialRef): void +``` + +Types: [CredentialRef](../core-data-structures/credentials.md) + +Source: [`packages/credentials/credentials/src/index.ts:67`](../../packages/credentials/credentials/src/index.ts) + ## `domain/*` ### `domain/changed` — emit diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 22bd74580b..3149d6b572 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -488,6 +488,52 @@ Types: [CompactionResult](../core-data-structures/compaction.md) · [CompactionT Source: [`packages/compact/compact/src/index.ts:54`](../../packages/compact/compact/src/index.ts) +## `ctx.credentials` — `Credentials` (abstract seam) + +Abstract credential service. Providers implement the four operations over their source layers; one seam-wide rule binds them all: an empty stored value is absent everywhere — `resolve` skips it, `describe` reports it unconfigured — so a blank never masquerades as a configured secret. + +```ts cordis-catalog +/** + * Resolve one reference to its current value. Resolution is per call: + * consumers re-resolve at each operation and must not cache across + * operations — that per-operation read is what makes a changed credential + * reach the next operation without a restart. + * @param ref - the reference to resolve. + * @returns the value and its source, or `undefined` while unconfigured. + */ +abstract resolve(ref: CredentialRef): Promise + +/** + * Describe one reference for configuration surfaces without exposing the + * value. + * @param ref - the reference to describe. + * @returns configured state, supplying source, and writability. + */ +abstract describe(ref: CredentialRef): Promise + +/** + * Durably store one value in the provider-managed writable source. Rejects + * while a read-only source shadows the reference — the write would appear + * to succeed while resolution keeps returning the shadowing value — and + * rejects an empty value (use {@link unset}). + * @param ref - the reference to store. + * @param value - the non-empty secret value. + */ +abstract set(ref: CredentialRef, value: string): Promise + +/** + * Remove one reference from the provider-managed writable source; removing + * an absent reference is a no-op. Rejects while a read-only source shadows + * the reference, like {@link set}. + * @param ref - the reference to remove. + */ +abstract unset(ref: CredentialRef): Promise +``` + +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:77`](../../packages/credentials/credentials/src/index.ts) + ## `ctx.directoryPicker` — `DirectoryPicker` (abstract seam) Abstract directory-picking service. Subclass, implement `capability()`, and load the subclass as a plugin — it registers as `ctx.directoryPicker` (one implementation per context; loading a second throws, cordis' standard duplicate-service behavior). The capability object must be stable for the service lifetime: consumers may capture it across calls. @@ -744,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. @@ -818,9 +864,9 @@ async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise ``` -Types: [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmCallConfig](../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) · [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:191`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:215`](../../packages/llm/llm/src/index.ts) ## `ctx.permission` — `PermissionService` diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index 28a148d7ab..e998f1f4a4 100644 --- a/docs/core-data-structures/core.i18n.yaml +++ b/docs/core-data-structures/core.i18n.yaml @@ -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: a12e96b4156b4ccd8f6f0c453224ed57d6040966 -core.zh.md: ac10c801bde8898ebd3393b05a94cc63627e1b89 +core.md: 09b437a8483134230d4b941c20940c5655bc53f0 +core.zh.md: 2025707db397203dbaec52c59172f83367f2033e diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index a12e96b415..09b437a848 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -25,6 +25,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t | [session.md](session.md) | the full `SessionEventMap` variant catalog, `TurnTrigger`/`TurnEndReason`, `deriveMessages()`, execution enclosure, and standalone events | | [persistence.md](persistence.md) | the durability seam: `SessionPersistence`, JSONL + SQLite backends, `session/flush`, crash recovery, `SessionHeader` | | [settings.md](settings.md) | the user-settings seam: `SettingsNamespace` registration, layered resolution (defaults → composition `base` → user document), owner scopes, hot commits | +| [credentials.md](credentials.md) | the credential seam: `CredentialRef` references (never values) in configuration, per-operation resolution, UI-safe `CredentialInfo`, provider source layers | | [session-query.md](session-query.md) | logical records, bounded exact-event reads, relationship traces, semantic filters/documents, and full-text result pages | | [session-title.md](session-title.md) | durable title snapshots, source provenance, and the asynchronous provider contract | | [system-prompt.md](system-prompt.md) | per-assembly context, tool-provider results, prompt sections, and cooperative assembly | @@ -182,6 +183,34 @@ 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. + * + * Throws `LlmError` with code `REGISTRATION_DISPOSED` once the registration + * has been released: its routes are gone and its disposer has already run, + * so anything registered afterwards would have no owner left to release it. + * @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 { diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index ac10c801bd..2025707db3 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -25,6 +25,7 @@ harness 是一个微内核:一个极小的核心加上众多插件。大多数 | [session.md](session.md) | 完整的 `SessionEventMap` 变体目录、`TurnTrigger`/`TurnEndReason`、`deriveMessages()`、执行封闭与独立事件 | | [persistence.md](persistence.md) | 持久性 seam:`SessionPersistence`、JSONL + SQLite 后端、`session/flush`、崩溃恢复、`SessionHeader` | | [settings.md](settings.md) | 用户设置 seam:`SettingsNamespace` 注册、分层解析(默认值 → 组合 `base` → 用户文档)、owner scope、热提交 | +| [credentials.md](credentials.md) | 凭据 seam:配置中的 `CredentialRef` 引用(绝不含值)、按操作解析、对 UI 安全的 `CredentialInfo`、provider 来源层 | | [session-query.md](session-query.md) | 逻辑记录、有界精确事件读取、关系追踪、语义筛选器/文档与全文检索结果页 | | [session-title.md](session-title.md) | 持久标题快照、来源 provenance 与异步提供方契约 | | [system-prompt.md](system-prompt.md) | 逐次组装的上下文、工具提供方结果、提示词段落与协作式组装 | @@ -188,6 +189,34 @@ 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. + * + * Throws `LlmError` with code `REGISTRATION_DISPOSED` once the registration + * has been released: its routes are gone and its disposer has already run, + * so anything registered afterwards would have no owner left to release it. + * @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 { diff --git a/docs/core-data-structures/credentials.i18n.yaml b/docs/core-data-structures/credentials.i18n.yaml new file mode 100644 index 0000000000..23bb940afe --- /dev/null +++ b/docs/core-data-structures/credentials.i18n.yaml @@ -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 docs/core-data-structures/credentials.md +credentials.md: 3f6fcd127d01e2c49e17c70c002bebe9f363e951 +credentials.zh.md: b5d2d9e164a85ce090790635c438b768cae4c9ca diff --git a/docs/core-data-structures/credentials.md b/docs/core-data-structures/credentials.md new file mode 100644 index 0000000000..3f6fcd127d --- /dev/null +++ b/docs/core-data-structures/credentials.md @@ -0,0 +1,50 @@ +# User Credentials + +English | [中文](credentials.zh.md) + +The credential seam of [dsh-credentials](../../packages/credentials/credentials) keeps secrets out of configuration: settings sections and `cordis.yml` entries carry *references* (environment-variable names), providers such as [dsh-credentials-local](../../packages/credentials/credentials-local) own the values, and consumers resolve a reference once per operation — the LLM adapters resolve once per model request, so a rotated credential reaches the very next request without any restart. One seam-wide rule binds every provider: an empty stored value is absent everywhere. + +Source: [`packages/credentials/credentials/src/index.ts`](../../packages/credentials/credentials/src/index.ts) + +## Identity + +A reference names one credential as a POSIX-style environment-variable name. The brand keeps references from mixing with other cross-boundary strings; construction validates the shell-identifier shape. + +```ts type-equiv +/** Nominal reference to one credential: a POSIX-style environment-variable name. */ +type CredentialRef = Branded<'CredentialRef'> +``` + +## Resolution + +`resolve(ref)` returns the value with the provider-defined source layer that supplied it, or `undefined` while unconfigured. Consumers re-resolve at each operation and never cache across operations — that per-operation read is the hot-update mechanism. + +```ts type-equiv +/** One resolved credential value and the source layer that supplied it. */ +interface ResolvedCredential { + /** The non-empty secret value. */ + value: string + /** Provider-defined source layer id (the local provider uses `env` and `file`). */ + source: string +} +``` + +## Description + +`describe(ref)` answers configuration surfaces without ever exposing a value: whether the reference resolves, from which layer, and whether `set` would currently succeed. The local provider reports a reference supplied by the live process environment as `writable: false` — a write would appear to succeed while resolution kept returning the shadowing value, so the seam rejects it and the UI can render the reference read-only up front. + +```ts type-equiv +/** Source and writability facts for one reference, safe for configuration UIs — never the value. */ +interface CredentialInfo { + /** Whether {@link Credentials.resolve} would currently return a value. */ + configured: boolean + /** Source layer currently supplying the value; absent while unconfigured. */ + source?: string + /** Whether {@link Credentials.set} would currently succeed for this reference. */ + writable: boolean +} +``` + +## Change commits + +`credentials/updated (ref)` fires after a committed change to a provider-managed source — a `set`, an `unset`, or an external edit observed in storage. Ambient process-environment changes are not observable and never emit. Consumers do not need the event (they re-resolve per operation); it exists for configuration surfaces refreshing a "configured" badge. diff --git a/docs/core-data-structures/credentials.zh.md b/docs/core-data-structures/credentials.zh.md new file mode 100644 index 0000000000..b5d2d9e164 --- /dev/null +++ b/docs/core-data-structures/credentials.zh.md @@ -0,0 +1,50 @@ +# 用户凭据 + +[English](credentials.md) | 中文 + +[dsh-credentials](../../packages/credentials/credentials) 的凭据 seam 把机密挡在配置之外:settings 分节与 `cordis.yml` 条目携带的是*引用*(环境变量名),值归 [dsh-credentials-local](../../packages/credentials/credentials-local) 这类 provider 所有,消费方每个操作解析一次引用——LLM 适配器每次模型请求解析一次,因此轮换后的凭据无需任何重启即可作用于紧随其后的下一次请求。一条 seam 级规则约束每个 provider:空的存储值在任何地方都视为不存在。 + +Source: [`packages/credentials/credentials/src/index.ts`](../../packages/credentials/credentials/src/index.ts) + +## 标识 + +引用以 POSIX 风格环境变量名命名一条凭据。brand 使引用不与其他跨边界字符串混用;构造时校验 shell 标识符形态。 + +```ts type-equiv +/** Nominal reference to one credential: a POSIX-style environment-variable name. */ +type CredentialRef = Branded<'CredentialRef'> +``` + +## 解析 + +`resolve(ref)` 返回值,连同供出该值、由 provider 定义的来源层;未配置期间返回 `undefined`。消费方在每个操作中重新解析,绝不跨操作缓存——这次按操作进行的读取正是热更新机制。 + +```ts type-equiv +/** One resolved credential value and the source layer that supplied it. */ +interface ResolvedCredential { + /** The non-empty secret value. */ + value: string + /** Provider-defined source layer id (the local provider uses `env` and `file`). */ + source: string +} +``` + +## 描述 + +`describe(ref)` 在绝不暴露值的前提下回应配置界面:引用当前是否可解析、来自哪一层、`set` 当前能否成功。本地 provider 把由活跃进程环境供值的引用报告为 `writable: false`——那样的写入会表面成功而解析持续返回遮蔽值,因此 seam 直接拒绝,界面也得以提前把该引用渲染为只读。 + +```ts type-equiv +/** Source and writability facts for one reference, safe for configuration UIs — never the value. */ +interface CredentialInfo { + /** Whether {@link Credentials.resolve} would currently return a value. */ + configured: boolean + /** Source layer currently supplying the value; absent while unconfigured. */ + source?: string + /** Whether {@link Credentials.set} would currently succeed for this reference. */ + writable: boolean +} +``` + +## 变更提交 + +`credentials/updated (ref)` 在 provider 管理的来源发生已提交变更后触发——`set`、`unset` 或在存储中观察到的外部编辑。进程环境自身的变化不可观测,永不发出事件。消费方不需要该事件(它们按操作重新解析);它服务于配置界面刷新「已配置」徽标。 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index f0dbda5137..950544938a 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -26,6 +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:67`](../packages/credentials/credentials/src/index.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`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`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`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`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-policy`](../packages/fs/fs-policy), [`skill-local`](../packages/skill/skill-local) | diff --git a/docs/module-graph.md b/docs/module-graph.md index ea5e1f4334..e0d102d237 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -8,6 +8,7 @@ Inter-package dependencies among the `@deepseek-ai/dsh-*` harness packages, deri ```mermaid flowchart TD subgraph group_util["packages/util"] + pkg_atomic_write["atomic-write"] pkg_brand["brand"] pkg_native_command["native-command"] pkg_paths["paths"] @@ -177,6 +178,10 @@ flowchart TD pkg_tmux_context["tmux-context"] pkg_workspace_context["workspace-context"] end + subgraph group_credentials["packages/credentials"] + pkg_credentials["credentials"] + pkg_credentials_local["credentials-local"] + end subgraph group_examples["packages/examples"] pkg_acp_demo["acp-demo"] pkg_agent_spine_demo["agent-spine-demo"] @@ -261,6 +266,7 @@ flowchart TD subgraph group_workspace["packages/workspace"] pkg_workspace["workspace"] end + pkg_atomic_write --> pkg_invariants pkg_brand --> pkg_invariants pkg_native_command --> pkg_invariants pkg_paths --> pkg_invariants @@ -315,6 +321,8 @@ flowchart TD pkg_client_ui_workspace --> pkg_client_ui_primitives pkg_client_ui_workspace --> pkg_client_ui_slots pkg_client_ui_workspace --> pkg_invariants + pkg_credentials --> pkg_brand + pkg_credentials --> pkg_invariants pkg_helper --> pkg_brand pkg_helper --> pkg_invariants pkg_helper --> pkg_subprocess @@ -333,11 +341,15 @@ flowchart TD pkg_subprocess_local --> pkg_subprocess pkg_typert_loader --> pkg_invariants pkg_typert_loader --> pkg_typert_registry + pkg_llm_deepseek --> pkg_credentials pkg_llm_deepseek --> pkg_invariants pkg_llm_deepseek --> pkg_llm + pkg_llm_deepseek --> pkg_settings pkg_llm_deepseek --> pkg_timeout + pkg_llm_pi_ai --> pkg_credentials pkg_llm_pi_ai --> pkg_invariants pkg_llm_pi_ai --> pkg_llm + pkg_llm_pi_ai --> pkg_settings pkg_llm_pi_ai --> pkg_timeout pkg_session --> pkg_brand pkg_session --> pkg_invariants @@ -371,6 +383,10 @@ flowchart TD pkg_client_ui_theme --> pkg_client_ui_primitives pkg_client_ui_theme --> pkg_client_ui_slots pkg_client_ui_theme --> pkg_invariants + pkg_credentials_local --> pkg_atomic_write + pkg_credentials_local --> pkg_credentials + pkg_credentials_local --> pkg_invariants + pkg_credentials_local --> pkg_paths pkg_host_directory_picker_browse --> pkg_client_locale pkg_host_directory_picker_browse --> pkg_client_runtime pkg_host_directory_picker_browse --> pkg_client_ui_primitives @@ -386,6 +402,7 @@ flowchart TD pkg_lsp --> pkg_llm pkg_sandbox --> pkg_invariants pkg_sandbox --> pkg_llm + pkg_settings_local --> pkg_atomic_write pkg_settings_local --> pkg_invariants pkg_settings_local --> pkg_paths pkg_settings_local --> pkg_settings @@ -999,6 +1016,7 @@ flowchart TD | Package | Group | Depends on | | --- | --- | --- | | [`invariants`](../packages/support/invariants) | `support` | — | +| [`atomic-write`](../packages/util/atomic-write) | `util` | [`invariants`](../packages/support/invariants) | | [`brand`](../packages/util/brand) | `util` | [`invariants`](../packages/support/invariants) | | [`native-command`](../packages/util/native-command) | `util` | [`invariants`](../packages/support/invariants) | | [`paths`](../packages/util/paths) | `util` | [`invariants`](../packages/support/invariants) | @@ -1033,6 +1051,7 @@ flowchart TD | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) | | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`credentials`](../packages/credentials/credentials) | `credentials` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) | | [`telemetry`](../packages/sdk/telemetry) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`settings`](../packages/settings/settings) | `settings` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | @@ -1041,8 +1060,8 @@ flowchart TD | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | | [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) | | [`typert-loader`](../packages/typert/loader) | `typert` | [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) | -| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | -| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | +| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | +| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | @@ -1051,11 +1070,12 @@ flowchart TD | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | -| [`settings-local`](../packages/settings/settings-local) | `settings` | [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`settings`](../packages/settings/settings) | +| [`settings-local`](../packages/settings/settings-local) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`settings`](../packages/settings/settings) | | [`token-meter`](../packages/llm/token-meter) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`agent`](../packages/core/agent) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`subprocess`](../packages/subprocess/subprocess) | diff --git a/examples/headless-agent/composition.md b/examples/headless-agent/composition.md index 53a01260e1..38774195e1 100644 --- a/examples/headless-agent/composition.md +++ b/examples/headless-agent/composition.md @@ -8,6 +8,10 @@ The headless demo combines the real DeepSeek adapter and coding capabilities wit ```mermaid flowchart LR cfg["examples/headless-agent
cordis.yml"] + plugin_headless_settings["settings
@deepseek-ai/dsh-settings-local"] + cfg --> plugin_headless_settings + plugin_headless_credentials["credentials
@deepseek-ai/dsh-credentials-local"] + cfg --> plugin_headless_credentials plugin_headless_llm_deepseek["llm-deepseek
@deepseek-ai/dsh-llm-deepseek"] cfg --> plugin_headless_llm_deepseek plugin_headless_subprocess["subprocess
@deepseek-ai/dsh-subprocess-local"] @@ -55,6 +59,8 @@ flowchart LR | Plugin id | Package / module | | --- | --- | +| `settings` | `@deepseek-ai/dsh-settings-local` | +| `credentials` | `@deepseek-ai/dsh-credentials-local` | | `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | | `subprocess` | `@deepseek-ai/dsh-subprocess-local` | | `bash` | `@deepseek-ai/dsh-bash-local` | diff --git a/examples/headless-agent/cordis.yml b/examples/headless-agent/cordis.yml index 896c73469b..3fc363ea0f 100644 --- a/examples/headless-agent/cordis.yml +++ b/examples/headless-agent/cordis.yml @@ -1,16 +1,27 @@ # One-shot coding agent with format-pure stdout. The app bin loads the -# gitignored root `.env`; this file reads `DEEPSEEK_API_KEY` and optional -# `DEEPSEEK_BASE_URL` through `!!js`. +# gitignored root `.env` into the process environment; entry configs here are +# the composition base, while user-plane values resolve per request through +# the two providers below. + +# User-settings document (`$DSH_HOME/settings.yaml`, hot-reloaded): a +# `llm-deepseek:` section there overrides the adapter entry below without a +# restart. +- id: settings + name: '@deepseek-ai/dsh-settings-local' + +# Credential store: the live process environment over `$DSH_HOME/.env` +# (owner-only file, hot-reloaded). The adapter resolves `DEEPSEEK_API_KEY` +# through it at each request, so no key is inlined in this file. +- id: credentials + name: '@deepseek-ai/dsh-credentials-local' # The DeepSeek adapter. Swap to '@deepseek-ai/dsh-llm-pi-ai' for the pi-ai-backed -# twin (same config shape; `reasoning: high` replaces thinking/reasoningEffort). -# Shipped default: full thinking at max effort on every request (wire-only -# defaults; they never enter the request header). +# twin (a `providers` dict keyed by route; `reasoning: high` replaces +# thinking/reasoningEffort). Shipped default: full thinking at max effort on +# every request (wire-only defaults; they never enter the request header). - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_BASE_URL thinking: enabled reasoningEffort: max models: diff --git a/examples/headless-agent/credentials.cordis.snapshot.yml b/examples/headless-agent/credentials.cordis.snapshot.yml new file mode 100644 index 0000000000..10bc2591c8 --- /dev/null +++ b/examples/headless-agent/credentials.cordis.snapshot.yml @@ -0,0 +1,18 @@ +# Keyless dynamic-configuration composition: the base settings and credentials +# providers see only the isolated run home, no API key exists anywhere, and +# the deepseek route still registers — so the prompt fails with the actionable +# MISSING_CREDENTIAL guidance this snapshot pins as first-run UX. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - insert: + # The endpoint is never dialed: credential resolution fails first. + - id: llm-deepseek-keyless + name: '@deepseek-ai/dsh-llm-deepseek' + config: + baseURL: 'http://127.0.0.1:9' diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts index 2581d8a042..a09e0281cc 100644 --- a/examples/headless-agent/tests/headless.snapshot.ts +++ b/examples/headless-agent/tests/headless.snapshot.ts @@ -27,6 +27,8 @@ const goalScenarioDir = join(snapshotsDir, 'goal-tools') const goalConfigPath = fileURLToPath(new URL('../goal.cordis.snapshot.yml', import.meta.url)) const retryScenarioDir = join(snapshotsDir, 'provider-retry') const retryConfigPath = fileURLToPath(new URL('../retry.cordis.snapshot.yml', import.meta.url)) +const credentialsScenarioDir = join(snapshotsDir, 'missing-credential') +const credentialsConfigPath = fileURLToPath(new URL('../credentials.cordis.snapshot.yml', import.meta.url)) const ralphScenarioDir = join(snapshotsDir, 'ralph-loop') const ralphConfigPath = fileURLToPath(new URL('../ralph.cordis.snapshot.yml', import.meta.url)) const binScript = fileURLToPath(new URL('../../../packages/examples/cli-demo/src/bin.ts', import.meta.url)) @@ -168,6 +170,40 @@ describe('headless stream-json snapshots', () => { expect(normalized).toBe(await readFile(streamExpected, 'utf8')) }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('surfaces actionable missing-credential guidance through the one-shot app', async () => { + const streamExpected = join(credentialsScenarioDir, 'stream-json.expected.jsonl') + let runCwd = '' + const result = await runLoaderSmoke({ + label: 'missing-credential headless stream-json snapshot', + tempDirPrefix: 'headless-snapshot-missing-credential-', + binScript, + configPath: credentialsConfigPath, + binArgs: ['--config', credentialsConfigPath, '--output-format', 'stream-json', 'say pong'], + tsconfigPath, + env: { + // First-run posture: no key in the environment, none under ./.dsh. + DEEPSEEK_API_KEY: '', + DEEPSEEK_BASE_URL: '', + NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '), + }, + // The designed failure surface: the one-shot app reports the failed turn. + expectedExitCode: 1, + 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";' + + ' 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) + expect(normalized).toBe(await readFile(streamExpected, 'utf8')) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('logs the model default and a dynamic next-step reasoning effort', async () => { const result = await runLoaderSmoke({ label: 'reasoning effort headless stream-json snapshot', diff --git a/examples/headless-agent/tests/snapshots/missing-credential/stream-json.expected.jsonl b/examples/headless-agent/tests/snapshots/missing-credential/stream-json.expected.jsonl new file mode 100644 index 0000000000..c48a42f62e --- /dev/null +++ b/examples/headless-agent/tests/snapshots/missing-credential/stream-json.expected.jsonl @@ -0,0 +1,8 @@ +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"say pong"}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":2,"time":0,"data":{"title":"say pong","messageSeqs":[1],"source":{"kind":"fallback"}}}} +{"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","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\"; 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\"; 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"}}} diff --git a/examples/package.json b/examples/package.json index d0f7d4143c..b7e7b5d736 100644 --- a/examples/package.json +++ b/examples/package.json @@ -22,6 +22,7 @@ "@deepseek-ai/dsh-commands": "workspace:*", "@deepseek-ai/dsh-compact-basic": "workspace:*", "@deepseek-ai/dsh-compact-tool-result-prune": "workspace:*", + "@deepseek-ai/dsh-credentials-local": "workspace:*", "@deepseek-ai/dsh-fs-local": "workspace:*", "@deepseek-ai/dsh-fs-policy": "workspace:*", "@deepseek-ai/dsh-fs-sandbox": "workspace:^", @@ -54,6 +55,7 @@ "@deepseek-ai/dsh-session-telemetry-otel": "workspace:*", "@deepseek-ai/dsh-session-title": "workspace:*", "@deepseek-ai/dsh-session-title-first-message-llm": "workspace:*", + "@deepseek-ai/dsh-settings-local": "workspace:*", "@deepseek-ai/dsh-skill": "workspace:*", "@deepseek-ai/dsh-skill-local": "workspace:*", "@deepseek-ai/dsh-spill-local": "workspace:*", diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml index fb1b330d86..369277ba3f 100644 --- a/packages/README.i18n.yaml +++ b/packages/README.i18n.yaml @@ -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/README.md -README.md: 11179cf6676d1b4382816e34285529b51152fe8d -README.zh.md: 100d918287613973604b2f85060572b8ee41d132 +README.md: 0c729f781151fcc0bda81899e51227e71c7b8d2b +README.zh.md: 660a24eeea5f1a36841654626d94412371a2f462 diff --git a/packages/README.md b/packages/README.md index 11179cf667..0c729f7811 100644 --- a/packages/README.md +++ b/packages/README.md @@ -40,6 +40,7 @@ Packages live at `packages///`; groups are containers, while names r | [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, bounded reads, lineage, event relationships, semantic filtering, and SQLite full-text search | Product — stable surface | | [`session-title/`](session-title/README.md) | Log-backed session titles: fallback service and opt-in LLM providers | Product — stable surface | | [`settings/`](settings/README.md) | User-settings seam + file-backed provider | Product — stable surface | +| [`credentials/`](credentials/README.md) | Credential-reference seam + env-over-`.env` provider | Product — stable surface | | [`telemetry/`](telemetry/README.md) | Session reporting: capture/redact seam, OTel backend | Product — stable surface | | [`storage/`](storage/README.md) | Non-session storage hub + backends + domain form | Product — stable surface | | [`workspace/`](workspace/README.md) | Workspace entity | Product — stable surface | diff --git a/packages/README.zh.md b/packages/README.zh.md index 100d918287..660a24eeea 100644 --- a/packages/README.zh.md +++ b/packages/README.zh.md @@ -40,6 +40,7 @@ | [`session-query/`](session-query/README.md) | 会话检索系列:逻辑语料库、有界读取、血缘、事件关系、语义过滤和 SQLite 全文搜索 | 产品:稳定表面 | | [`session-title/`](session-title/README.md) | 日志支撑的会话标题:回退服务与选用 LLM 提供方 | 产品:稳定表面 | | [`settings/`](settings/README.md) | 用户设置 seam + 文件 provider | 产品:稳定表面 | +| [`credentials/`](credentials/README.md) | 凭据引用 seam + 环境叠加 `.env` provider | 产品:稳定表面 | | [`telemetry/`](telemetry/README.md) | 会话上报:捕获/脱敏 seam、OTel 后端 | 产品:稳定表面 | | [`storage/`](storage/README.md) | 非会话存储中枢 + 后端 + 领域形式 | 产品:稳定表面 | | [`workspace/`](workspace/README.md) | Workspace 实体 | 产品:稳定表面 | diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 34c2813603..1c9473b245 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -264,6 +264,28 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'credentials', + summary: 'Abstract credential service.', + methods: [ + { + signature: 'abstract resolve(ref: CredentialRef): Promise', + jsDoc: '/**\n * Resolve one reference to its current value. Resolution is per call:\n * consumers re-resolve at each operation and must not cache across\n * operations — that per-operation read is what makes a changed credential\n * reach the next operation without a restart.\n * @param ref - the reference to resolve.\n * @returns the value and its source, or `undefined` while unconfigured.\n */', + }, + { + signature: 'abstract describe(ref: CredentialRef): Promise', + jsDoc: '/**\n * Describe one reference for configuration surfaces without exposing the\n * value.\n * @param ref - the reference to describe.\n * @returns configured state, supplying source, and writability.\n */', + }, + { + signature: 'abstract set(ref: CredentialRef, value: string): Promise', + jsDoc: '/**\n * Durably store one value in the provider-managed writable source. Rejects\n * while a read-only source shadows the reference — the write would appear\n * to succeed while resolution keeps returning the shadowing value — and\n * rejects an empty value (use {@link unset}).\n * @param ref - the reference to store.\n * @param value - the non-empty secret value.\n */', + }, + { + signature: 'abstract unset(ref: CredentialRef): Promise', + jsDoc: '/**\n * Remove one reference from the provider-managed writable source; removing\n * an absent reference is a no-op. Rejects while a read-only source shadows\n * the reference, like {@link set}.\n * @param ref - the reference to remove.\n */', + }, + ], + }, { key: 'directoryPicker', summary: 'Abstract directory-picking service.', @@ -383,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[]', @@ -1271,6 +1293,13 @@ export const EVENT_API: readonly EventApiEntry[] = [ jsDoc: '/**\n * A command was registered or unregistered. This is an unfiltered registry\n * notification because a global or scoped change may affect any UI view.\n * Observer failures are contained and cannot veto the registry mutation.\n * @mode emit\n */', summary: 'A command was registered or unregistered.', }, + { + 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. 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.', + }, { name: 'domain/changed', mode: 'emit', @@ -1492,6 +1521,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;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n}', @@ -1720,6 +1753,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'CreateSessionOptions', declaration: 'export interface CreateSessionOptions {\n readonly seed?: readonly SessionEvent[];\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly createdAt?: number;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n };\n}', }, + { + name: 'CredentialInfo', + declaration: 'export interface CredentialInfo {\n configured: boolean;\n source?: string;\n writable: boolean;\n}', + }, + { + name: 'CredentialRef', + declaration: 'export type CredentialRef = Branded<\'CredentialRef\'>;', + }, { name: 'DiffCallView', declaration: 'export interface DiffCallView {\n card: \'diff\';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n}', @@ -2148,6 +2189,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ResolvedAlwaysRetryPolicy', declaration: 'export interface ResolvedAlwaysRetryPolicy extends ResolvedRetryBackoff {\n readonly mode: \'always\';\n}', }, + { + name: 'ResolvedCredential', + declaration: 'export interface ResolvedCredential {\n value: string;\n source: string;\n}', + }, { name: 'ResolvedNormalRetryPolicy', declaration: 'export interface ResolvedNormalRetryPolicy extends ResolvedRetryBackoff {\n readonly mode: \'normal\';\n readonly maxRetries: number;\n readonly retryableCodes: readonly string[];\n}', diff --git a/packages/credentials/README.i18n.yaml b/packages/credentials/README.i18n.yaml new file mode 100644 index 0000000000..e8b35ba48e --- /dev/null +++ b/packages/credentials/README.i18n.yaml @@ -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 packages/credentials/README.md +README.md: 1d450cbeef84750fa57ca0151563c496aed0ce12 +README.zh.md: 843230c3cebf35f234d3ad812165b16ea734678b diff --git a/packages/credentials/README.md b/packages/credentials/README.md new file mode 100644 index 0000000000..1d450cbeef --- /dev/null +++ b/packages/credentials/README.md @@ -0,0 +1,14 @@ +# credentials/ + +English | [中文](README.zh.md) + +The credential capability seam, as three-package shape dictates (interface / implementation / consumers): + +| Package | Role | +|---|---| +| [`credentials/`](credentials/README.md) | Abstract `ctx.credentials`: branded `CredentialRef` references, per-operation `resolve`, UI-safe `describe`, fail-loud `set`/`unset`, the `credentials/updated` commit event | +| [`credentials-local/`](credentials-local/README.md) | File/environment provider: the live process environment (read-only, wins) layered over `$DSH_HOME/.env` (writable, byte-preserving line edits, hot-reloaded) | + +Configuration files carry *references* to secrets (`apiKeyEnv: DEEPSEEK_API_KEY`), never the secrets: the settings document stays safe to sync and render, and rotating a value touches no configuration. The LLM adapters are the first consumers — they resolve their reference once per model request, which is what makes a key stored moments ago reach the very next request without restarting anything. + +The seam shape leaves room for keyring-, helper-command-, and KMS-backed providers. diff --git a/packages/credentials/README.zh.md b/packages/credentials/README.zh.md new file mode 100644 index 0000000000..843230c3ce --- /dev/null +++ b/packages/credentials/README.zh.md @@ -0,0 +1,14 @@ +# credentials/ + +[English](README.md) | 中文 + +凭据能力 seam,按三包形态的要求组织(接口/实现/消费方): + +| 包 | 角色 | +|---|---| +| [`credentials/`](credentials/README.md) | 抽象 `ctx.credentials`:品牌化 `CredentialRef` 引用、按操作 `resolve`、对 UI 安全的 `describe`、响亮失败的 `set`/`unset`,以及 `credentials/updated` 提交事件 | +| [`credentials-local/`](credentials-local/README.md) | 文件/环境 provider:活跃进程环境(只读、优先)叠加在 `$DSH_HOME/.env`(可写、保字节行级编辑、热重载)之上 | + +配置文件携带的是对机密的*引用*(`apiKeyEnv: DEEPSEEK_API_KEY`),绝不携带机密本身:设置文档可以放心同步与渲染,轮换值不触碰任何配置。LLM 适配器是第一批消费方——它们每次模型请求解析一次引用,正因如此,片刻前存入的密钥无需重启任何组件即可作用于紧随其后的下一次请求。 + +seam 形状为 keyring、辅助命令与 KMS 后端的 provider 留有余地。 diff --git a/packages/credentials/credentials-local/README.i18n.yaml b/packages/credentials/credentials-local/README.i18n.yaml new file mode 100644 index 0000000000..89a8576683 --- /dev/null +++ b/packages/credentials/credentials-local/README.i18n.yaml @@ -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 packages/credentials/credentials-local/README.md +README.md: 126140b10719dc6f7bc458a118ba1feb1f440270 +README.zh.md: c22575115ab44b5e86a847ffe8f1fa1a795b580d diff --git a/packages/credentials/credentials-local/README.md b/packages/credentials/credentials-local/README.md new file mode 100644 index 0000000000..126140b107 --- /dev/null +++ b/packages/credentials/credentials-local/README.md @@ -0,0 +1,54 @@ +# dsh-credentials-local + +English | [中文](README.zh.md) + +File-backed [credentials](../credentials/README.md) provider: two layers, one honest precedence. + +| Layer | Source id | Writable | Wins | +|---|---|---|---| +| Live process environment | `env` | no | always | +| `$DSH_HOME/.env` document | `file` | yes (`set`/`unset`) | otherwise | + +The environment wins because a launch-time override (`DEEPSEEK_API_KEY=… dsh`, CI secrets, a dev shell sourcing the repo `.env`) is operator intent for this run — and because it cannot be edited from inside, it must be *visibly* read-only: `describe()` reports `source: 'env', writable: false`, and `set`/`unset` reject instead of writing a change the reader would never see. Resolution reads `process.env` live and never writes it back. + +## Config + +| Field | Default | Meaning | +|---|---|---| +| `path` | `/.env` | Credentials document location. | +| `dshHome` | `$DSH_HOME` or `~/.dsh` | Harness home used when `path` is omitted. | +| `watch` | `true` | Hot-publish external edits. | +| `debounceMs` | `100` | Watcher write-settle window. | + +## The document + +dotenv format, parsed with `dotenv` and edited by a physical-line editor that preserves every byte it does not own: `set` rewrites the first assignment of its key in place with that line's own ending (dropping later duplicates, which dotenv's last-wins reading would otherwise let override the edit), `unset` removes only the owning line, and comments, unrelated lines, CRLF endings, and the continuation lines of another key's quoted multi-line value all survive verbatim. Every write first re-reads the document under the cross-process writer lock of [`dsh-atomic-write`](../../util/atomic-write/README.md) and publishes anything it had not observed, then commits atomically with mode `0600` under an owner-only (`0700`) directory — so a concurrent writer or an external edit inside the watcher's debounce window is folded in rather than overwritten. + +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. + +## Hot reload + +External edits publish `credentials/updated` per changed reference after the snapshot is replaced **wholesale** — an entry deleted on disk never lingers in memory. The provider's own writes are recognized by content and publish exactly their one commit event. An unreadable document at runtime keeps the last good snapshot and warns; an absent file is an empty store; an unreadable file at boot fails loud. Keys that are not POSIX identifiers are preserved file content the seam cannot address. + +## 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. + +#### KV Cache effect + +No direct invalidation; credentials never enter a request prefix. + +## Known Limitations and Deferred Work + +- **Multi-line entries refuse `set`/`unset`** — the line editor will not rewrite an entry it would corrupt; `describe` reports them `writable: false` and edits must go to the file directly. +- **Same-reference concurrent writes are last-write-wins** — the writer lock and the read-modify-write keep concurrent writers from dropping each other's entries, but two writers editing one reference still resolve to the later write; there is no revision check. +- **A same-UID process can read the document** — see [Security boundary](#security-boundary): 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. diff --git a/packages/credentials/credentials-local/README.zh.md b/packages/credentials/credentials-local/README.zh.md new file mode 100644 index 0000000000..c22575115a --- /dev/null +++ b/packages/credentials/credentials-local/README.zh.md @@ -0,0 +1,54 @@ +# dsh-credentials-local + +[English](README.md) | 中文 + +文件型[凭据](../credentials/README.md) provider:两层来源,一条诚实的优先级。 + +| 层 | 来源 id | 可写 | 优先 | +|---|---|---|---| +| 活跃进程环境 | `env` | 否 | 恒定优先 | +| `$DSH_HOME/.env` 文档 | `file` | 是(`set`/`unset`) | 其余情况 | + +环境优先,因为启动时覆盖(`DEEPSEEK_API_KEY=… dsh`、CI 机密、加载了仓库 `.env` 的开发 shell)代表本次运行的操作者意图——而它无法从进程内部修改,就必须*可见地*只读:`describe()` 报告 `source: 'env', writable: false`,`set`/`unset` 直接拒绝,而不是写下一个读取方永远看不到的变更。解析实时读取 `process.env`,绝不写回。 + +## 配置 + +| 字段 | 默认值 | 含义 | +|---|---|---| +| `path` | `/.env` | 凭据文档位置。 | +| `dshHome` | `$DSH_HOME` 或 `~/.dsh` | `path` 缺省时使用的 harness home。 | +| `watch` | `true` | 热发布外部编辑。 | +| `debounceMs` | `100` | watcher 写入稳定窗口。 | + +## 文档本身 + +dotenv 格式,用 `dotenv` 解析;写回用物理行级编辑器,保留一切不属于本次编辑的字节:`set` 原位改写该键的第一条赋值行、沿用该行自身的行尾(丢弃后续重复行——dotenv 按最后一条生效,重复行会反过来覆盖这次编辑),`unset` 只删除所属行,注释、无关行、CRLF 行尾,以及另一个键的引号多行值的续行,都逐字保留。每次写入都先在 [`dsh-atomic-write`](../../util/atomic-write/README.md) 的跨进程写锁下重读文档、把此前未观察到的一切发布出去,再在仅属主可访问(`0700`)的目录下以 `0600` 权限原子提交——因此并发写入者、或落在 watcher 防抖窗口内的外部编辑会被并入,而不是被覆盖。 + +值按 dotenv 能逐字读回的最窄样式渲染——裸值,其次单引号(完全字面),再次双引号(仅限无反斜杠,双引号读取会展开转义)。任何样式都无法表示的值,以及已经跨越多个物理行的条目,都会响亮失败而不是被静默破坏。空的存储值等于不存在(seam 规则)。 + +## 热重载 + +外部编辑在快照**整体替换**后按变更引用逐个发布 `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 适配器间接生效:存储的值为适配器的提供方请求授权,每个模型可见面都归适配器所有。 + +#### KV Cache effect + +无直接失效;凭据绝不进入请求前缀。 + +## Known Limitations and Deferred Work + +- **多行条目拒绝 `set`/`unset`**——行编辑器不改写会被它破坏的条目;`describe` 把它们报为 `writable: false`,编辑必须直接落到文件上。 +- **同一引用的并发写入是后写胜出**——写锁加读-改-写让并发写入者不会丢掉彼此的条目,但两个写入者编辑同一个引用时仍以较后的写入为准;没有修订检查。 +- **同 UID 进程可以读取该文档**——见[安全边界](#security-boundary):只有受限沙箱模式会拒绝它,OS 钥匙串 provider 仍是延后项。 +- **无法表示的值响亮失败**——控制字符,或同时混用两种引号又含反斜杠的值,无法在 dotenv 行格式中往返。 +- **环境变化不可见**——每次解析实时读取 `process.env`,但那里的变化不可能发出事件。 +- **原子但不保证崩溃持久**——继承自 `dsh-atomic-write`;存储在启动时重新读取。 diff --git a/packages/credentials/credentials-local/package.json b/packages/credentials/credentials-local/package.json new file mode 100644 index 0000000000..0b8924d7f2 --- /dev/null +++ b/packages/credentials/credentials-local/package.json @@ -0,0 +1,48 @@ +{ + "name": "@deepseek-ai/dsh-credentials-local", + "description": "File-backed credentials provider ($DSH_HOME/.env under the live process environment) for the DeepSeek Harness", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-atomic-write": "^0.0.1", + "@deepseek-ai/dsh-credentials": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-paths": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "chokidar": "^4.0.3", + "dotenv": "^17.2.0", + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-atomic-write": "workspace:^", + "@deepseek-ai/dsh-credentials": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-paths": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/credentials/credentials-local/src/index.ts b/packages/credentials/credentials-local/src/index.ts new file mode 100644 index 0000000000..c62d4411fa --- /dev/null +++ b/packages/credentials/credentials-local/src/index.ts @@ -0,0 +1,464 @@ +/** + * File-backed credentials provider layering the live process environment over + * a `$DSH_HOME/.env` document. The environment is authoritative and read-only + * (a launch-time override must win, and must be visibly read-only rather than + * silently shadow writes); the file is the provider-managed writable source: + * every write re-reads the document under a cross-process writer lock before + * rewriting only its own line — preserving every other byte, physical line + * endings and quoted multi-line values included — external edits hot-publish + * through the seam, and each reload replaces the snapshot wholesale so a + * deleted entry never lingers in memory. + * @module @deepseek-ai/dsh-credentials-local + */ + +import { Context, Service } from 'cordis' +import z from 'schemastery' +import { watch as chokidarWatch } from 'chokidar' +import { mkdir, readFile } from 'node:fs/promises' +import { dirname, join, resolve } from 'node:path' +import { parse } from 'dotenv' +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' + +/** Plugin config: file location and hot-reload behavior. */ +export interface Config { + /** Credentials document path; defaults to `.env` under the harness home. */ + path?: string + /** Harness home used when `path` is omitted; defaults to `$DSH_HOME` or `~/.dsh`. */ + dshHome?: string + /** Watch the document and hot-publish external edits; defaults to true. */ + watch?: boolean + /** Watcher write-settle window in milliseconds; defaults to 100. */ + debounceMs?: number +} + +/** Fully resolved provider parameters; defaulting happens here, never inline. */ +interface ResolvedSpec { + filename: string + watch: boolean + debounceMs: number +} + +/** + * Resolve the runtime spec from plugin config: an explicit `path` wins, + * otherwise the document lives at `/.env`. + * @param config - raw plugin config. + * @returns the resolved file location and watch behavior. + */ +export function resolveSpec(config: Config): ResolvedSpec { + return { + filename: resolve(config.path ?? join(resolveDshHome(config.dshHome), '.env')), + watch: config.watch ?? true, + debounceMs: config.debounceMs ?? 100, + } +} + +/** Whether a filesystem error means absence; every non-ENOENT failure must surface. */ +function isENOENT(error: unknown): boolean { + return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT' +} + +/** Values that survive a dotenv round-trip without quoting. */ +const BARE_VALUE = /^[A-Za-z0-9_@%+:,./-]+$/ + +/** Whether a value contains C0 control characters (newlines included) no dotenv style reads back. */ +function hasControlCharacters(value: string): boolean { + for (const char of value) { + if (char.charCodeAt(0) < 0x20) return true + } + return false +} + +/** + * Render one `KEY=value` line in the narrowest style dotenv reads back + * verbatim: bare, then single quotes (fully literal), then double quotes + * (safe only without backslashes, which double-quote reading expands). + * A value no style can represent fails loud instead of corrupting silently. + */ +function renderLine(ref: CredentialRef, value: string): string { + if (BARE_VALUE.test(value)) return `${ref}=${value}` + if (hasControlCharacters(value)) { + throw new Error(`credentials-local: the value for "${ref}" contains control characters the .env line format cannot represent`) + } + if (!value.includes('\'')) return `${ref}='${value}'` + if (!value.includes('"') && !value.includes('\\')) return `${ref}="${value}"` + throw new Error(`credentials-local: the value for "${ref}" mixes quoting no .env style can represent; edit the file directly`) +} + +/** Split text into physical lines with their terminators attached. */ +function physicalLines(text: string): string[] { + return text.length === 0 ? [] : text.split(/(?<=\n)/) +} + +/** One physical line's content without its terminator. */ +function lineContent(line: string): string { + if (line.endsWith('\r\n')) return line.slice(0, -2) + if (line.endsWith('\n')) return line.slice(0, -1) + return line +} + +/** One physical line's terminator (empty on a final unterminated line). */ +function lineTerminator(line: string): string { + return line.slice(lineContent(line).length) +} + +/** An assignment line: optional export, a POSIX identifier, `=`, the value part. */ +const ASSIGNMENT = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/ + +/** Quote characters dotenv reads across physical lines. */ +const MULTILINE_QUOTES = ['\'', '"', '`'] + +/** + * The quote character an assignment's value part opens without closing on its + * own line — the following physical lines are that value's continuation, not + * assignments — or `undefined` for a single-line value. + */ +function opensMultiline(valuePart: string): string | undefined { + const trimmed = valuePart.trimStart() + const quote = trimmed[0] + if (quote === undefined || !MULTILINE_QUOTES.includes(quote)) return undefined + const rest = trimmed.slice(1) + const body = quote === '"' ? rest.replaceAll('\\"', '') : rest + return body.includes(quote) ? undefined : quote +} + +/** Whether a continuation line closes the given quote. */ +function closesQuote(content: string, quote: string): boolean { + const body = quote === '"' ? content.replaceAll('\\"', '') : content + return body.includes(quote) +} + +/** + * 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 + let pendingQuote: string | undefined + for (const line of lines) { + const content = lineContent(line) + if (pendingQuote !== undefined) { + // Inside a quoted multi-line value: never an assignment, always kept. + if (closesQuote(content, pendingQuote)) pendingQuote = undefined + out.push(line) + continue + } + const match = ASSIGNMENT.exec(content) + if (match === null) { + out.push(line) + continue + } + const [, key, valuePart] = match + if (key !== ref) { + /* v8 ignore next -- the value group is `(.*)`, which always participates; the fallback only satisfies noUncheckedIndexedAccess */ + pendingQuote = opensMultiline(valuePart ?? '') + out.push(line) + continue + } + // The write path refuses multi-line targets before rendering, so the + // matched assignment is single-line and drops or rewrites wholesale. + if (rendered !== undefined && !placed) { + out.push(`${rendered}${lineTerminator(line) === '' ? dominant : lineTerminator(line)}`) + placed = true + } + } + if (rendered !== undefined && !placed) { + const last = out[out.length - 1] + if (last !== undefined && lineTerminator(last) === '') { + out[out.length - 1] = `${last}${dominant}` + } + out.push(`${rendered}${dominant}`) + } + return out.join('') +} + +/** File-backed credentials provider (`$DSH_HOME/.env`). */ +export class CredentialsLocal extends Credentials { + /* jscpd:ignore-start -- deliberate config-surface and lifecycle symmetry with + settings-local (prefer symmetry for parallel values); extracting the shared + shape would couple the two providers' teardown semantics across packages. */ + static Config: z = z.object({ + path: z.string(), + dshHome: z.string(), + watch: z.boolean().default(true), + debounceMs: z.number().min(0).default(100), + }) + + private readonly spec: ResolvedSpec + /** + * Raw text of the last read or persisted document; `undefined` while the + * file is absent. Watcher events whose content equals this cache are no-ops, + * which is also the self-write suppression. + */ + private text: string | undefined + /** Parsed document snapshot; replaced wholesale on every reload. */ + private values = new Map() + /** + * 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 = Promise.resolve() + /** Set at dispose: refuse new writes and let in-flight work no-op. */ + private closed = false + + /** Opaque read of {@link closed}: control flow cannot narrow it across awaits. */ + private isClosed(): boolean { + return this.closed + } + /* jscpd:ignore-end */ + + constructor(ctx: Context, public config: Config) { + super(ctx) + // Programmatic construction may bypass Schemastery normalization; resolve + // the same defaults in one explicit step either way. + this.spec = resolveSpec(config) + } + + async* [Service.init](): AsyncGenerator<() => Promise | void, void, void> { + yield async () => { + // Drain: refuse new operations, then settle the queued ones so disposal + // completes only once storage is quiescent. + this.closed = true + await this.operations + } + await this.loadInitial() + if (!this.spec.watch) return + /* jscpd:ignore-start -- same watcher discipline as settings-local by design: + the serialized-refresh and quiesce-on-dispose shape is the reviewed + lifecycle contract, not accidental repetition. */ + const watcher = chokidarWatch(this.spec.filename, { + ignoreInitial: true, + awaitWriteFinish: { + stabilityThreshold: this.spec.debounceMs, + pollInterval: Math.max(1, Math.min(this.spec.debounceMs, 10)), + }, + }) + watcher.on('all', () => { + if (this.closed) return + 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) + }) + yield async () => { + // Quiesce: stop accepting events, close the watcher, then wait out any + // queued or in-flight operation so nothing publishes after disposal. + this.closed = true + await watcher.close() + await this.operations + } + /* jscpd:ignore-end */ + } + + override resolve(ref: CredentialRef): Promise { + const env = process.env[ref] + if (env !== undefined && env.length > 0) return Promise.resolve({ value: env, source: 'env' }) + const stored = this.values.get(ref) + if (stored !== undefined && stored.length > 0) return Promise.resolve({ value: stored, source: 'file' }) + return Promise.resolve(undefined) + } + + override describe(ref: CredentialRef): Promise { + const env = process.env[ref] + if (env !== undefined && env.length > 0) { + return Promise.resolve({ configured: true, source: 'env', writable: false }) + } + const stored = this.values.get(ref) + if (stored !== undefined && stored.length > 0) { + // A quoted multi-line value resolves fine but the line editor refuses to + // rewrite it, so writability must say what set() would actually do. + return Promise.resolve({ configured: true, source: 'file', writable: !stored.includes('\n') }) + } + return Promise.resolve({ configured: false, writable: true }) + } + + override async set(ref: CredentialRef, value: string): Promise { + if (value.length === 0) { + throw new Error(`credentials-local: an empty value cannot be stored for "${ref}"; use unset`) + } + await this.write(ref, value) + } + + override async unset(ref: CredentialRef): Promise { + 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(operation: () => Promise): Promise { + 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 { + const verb = value === undefined ? 'unset' : 'set' + if (this.isClosed()) { + throw new Error(`credentials-local is disposed: cannot ${verb} "${ref}"`) + } + this.assertUnshadowed(ref, verb) + 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) + // 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) + }, + }) + }) + } + + /** Reject a write the live environment would shadow into apparent no-effect. */ + private assertUnshadowed(ref: CredentialRef, verb: 'set' | 'unset'): void { + const env = process.env[ref] + if (env !== undefined && env.length > 0) { + throw new Error( + `credentials-local: "${ref}" is supplied read-only by the process environment, so ${verb} would be` + + ' shadowed; change the launching environment instead', + ) + } + } + + /** Boot read: an absent file is an empty store; any other failure is loud. */ + private async loadInitial(): Promise { + let text: string + try { + text = await readFile(this.spec.filename, 'utf8') + } catch (error) { + if (!isENOENT(error)) throw error + return + } + this.text = text + 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. An invariant violation escaping the fan-out is not a reload + * failure and propagates to the queue's error surface. + */ + private async refresh(): Promise { + 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 { + let text: string | undefined + try { + text = await readFile(this.spec.filename, 'utf8') + } catch (error) { + if (!isENOENT(error)) throw error + text = undefined + } + if (text === this.text || this.isClosed()) return + const next = text === undefined ? new Map() : new Map(Object.entries(parse(text))) + const changed = this.changedRefs(this.values, next) + this.text = text + this.values = next + for (const ref of changed) this.notifyUpdated(ref) + } + /* jscpd:ignore-end */ + + /** Seam-addressable entries whose effective (non-empty) value changed. */ + private changedRefs(prev: Map, next: Map): CredentialRef[] { + const changed: CredentialRef[] = [] + for (const key of new Set([...prev.keys(), ...next.keys()])) { + const before = prev.get(key) + const after = next.get(key) + const effectiveBefore = before !== undefined && before.length > 0 ? before : undefined + const effectiveAfter = after !== undefined && after.length > 0 ? after : undefined + if (effectiveBefore === effectiveAfter) continue + try { + changed.push(credentialRef(key)) + } catch (_unaddressableKey) { + // A key that is not a POSIX identifier is preserved file content the + // seam cannot address, so no observer could ever see it change. + } + } + return changed + } +} + +export default CredentialsLocal diff --git a/packages/credentials/credentials-local/src/invariant.ts b/packages/credentials/credentials-local/src/invariant.ts new file mode 100644 index 0000000000..9ec75ed21d --- /dev/null +++ b/packages/credentials/credentials-local/src/invariant.ts @@ -0,0 +1,31 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-credentials-local`. + * @module @deepseek-ai/dsh-credentials-local/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-credentials-local' + +/** Cordis companion plugin name. */ +export const name = 'credentials-local-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: the seam companion (`dsh-credentials/invariant`) owns the + * `credentials/updated` lifecycle contract; this provider's file/environment layering is + * asynchronous I/O pinned by its unit suite. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/credentials/credentials-local/tests/drain.spec.ts b/packages/credentials/credentials-local/tests/drain.spec.ts new file mode 100644 index 0000000000..baefbd52c5 --- /dev/null +++ b/packages/credentials/credentials-local/tests/drain.spec.ts @@ -0,0 +1,71 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import { mkdtemp, rm } 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' + +// 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() + let gate: Promise = Promise.resolve() + return { + ...actual, + writeFileAtomic: vi.fn(() => gate), + __setGate: (next: Promise) => { + gate = next + }, + } +}) + +async function setGate(next: Promise): Promise { + const mocked = await import('@deepseek-ai/dsh-atomic-write') as unknown as { __setGate: (next: Promise) => void } + mocked.__setGate(next) +} + +const KEY = credentialRef('DSH_CRED_DRAIN_A') +const OTHER = credentialRef('DSH_CRED_DRAIN_B') + +const cleanups: Array<() => Promise> = [] + +afterEach(async () => { + await setGate(Promise.resolve()) + while (cleanups.length > 0) await cleanups.pop()!() +}) + +describe('write-drain teardown', () => { + it('lets the in-flight write land and fails the queued one after disposal', async () => { + const dir = await mkdtemp(join(tmpdir(), 'dsh-credentials-drain-')) + cleanups.push(() => rm(dir, { recursive: true, force: true })) + const ctx = new Context() + const fiber = ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false }) + await fiber + const service = ctx.credentials + + let release!: () => void + await setGate(new Promise((resolveGate) => { + release = resolveGate + })) + const first = service.set(KEY, 'one') + // Let the first task pass its liveness checks and park on the gate, so it + // is genuinely in-flight when disposal begins. + await new Promise(resolvePause => setTimeout(resolvePause, 5)) + // Attach the rejection handler up front: the queued write fails while the + // drain is still awaited, before any later `await expect` could run. + const secondRejects = expect(service.set(OTHER, 'two')).rejects.toThrow(/disposed before the queued/) + const disposal = fiber.dispose() + // Give the drain disposer its first turn (set closed) before opening the gate. + await new Promise(resolvePause => setTimeout(resolvePause, 10)) + release() + await disposal + + await expect(first).resolves.toBeUndefined() + await secondRejects + expect(await service.resolve(KEY)).toEqual({ value: 'one', source: 'file' }) + expect(await service.resolve(OTHER)).toBeUndefined() + }) +}) diff --git a/packages/credentials/credentials-local/tests/local.spec.ts b/packages/credentials/credentials-local/tests/local.spec.ts new file mode 100644 index 0000000000..4ebaed1a0c --- /dev/null +++ b/packages/credentials/credentials-local/tests/local.spec.ts @@ -0,0 +1,244 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { credentialRef } from '@deepseek-ai/dsh-credentials' +import type { CredentialRef } from '@deepseek-ai/dsh-credentials' +import { CredentialsLocal, resolveSpec } from '../src/index.ts' + +const KEY = credentialRef('DSH_CRED_TEST') +const OTHER = credentialRef('DSH_CRED_OTHER') + +const cleanups: Array<() => Promise> = [] + +afterEach(async () => { + vi.unstubAllEnvs() + while (cleanups.length > 0) await cleanups.pop()!() +}) + +async function tempDir(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'dsh-credentials-local-')) + cleanups.push(() => rm(dir, { recursive: true, force: true })) + return dir +} + +async function boot(config: ConstructorParameters[1]): Promise { + const ctx = new Context() + const fiber = ctx.plugin(CredentialsLocal, config) + cleanups.push(async () => { + await fiber.dispose() + }) + await fiber + return ctx +} + +function updates(ctx: Context): CredentialRef[] { + const seen: CredentialRef[] = [] + ctx.on('credentials/updated', (ref) => { + seen.push(ref) + }) + return seen +} + +describe('resolveSpec', () => { + it('defaults to .env under the harness home with watching on', () => { + const spec = resolveSpec({ dshHome: '/custom/home' }) + expect(spec).toEqual({ filename: resolve('/custom/home/.env'), watch: true, debounceMs: 100 }) + }) + + it('lets an explicit path win over the home', () => { + const spec = resolveSpec({ path: '/etc/dsh/creds.env', dshHome: '/ignored', watch: false, debounceMs: 5 }) + expect(spec).toEqual({ filename: resolve('/etc/dsh/creds.env'), watch: false, debounceMs: 5 }) + }) +}) + +describe('layering and reads', () => { + it('treats an absent file as an empty writable store', async () => { + const dir = await tempDir() + const ctx = await boot({ path: join(dir, '.env'), watch: false }) + expect(await ctx.credentials.resolve(KEY)).toBeUndefined() + expect(await ctx.credentials.describe(KEY)).toEqual({ configured: false, writable: true }) + }) + + it('serves file entries, including export-prefixed and quoted values', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + await writeFile(path, '# notes\nexport DSH_CRED_TEST=plain\nDSH_CRED_OTHER="with space"\n') + const ctx = await boot({ path, watch: false }) + expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'plain', source: 'file' }) + expect(await ctx.credentials.resolve(OTHER)).toEqual({ value: 'with space', source: 'file' }) + expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'file', writable: true }) + }) + + it('lets a non-empty process environment win read-only over the file', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + await writeFile(path, 'DSH_CRED_TEST=from-file\n') + const ctx = await boot({ path, watch: false }) + vi.stubEnv('DSH_CRED_TEST', 'from-env') + expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'from-env', source: 'env' }) + expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'env', writable: false }) + }) + + it('treats empty values as absent in both layers', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + await writeFile(path, 'DSH_CRED_TEST=\n') + const ctx = await boot({ path, watch: false }) + vi.stubEnv('DSH_CRED_TEST', '') + expect(await ctx.credentials.resolve(KEY)).toBeUndefined() + expect(await ctx.credentials.describe(KEY)).toEqual({ configured: false, writable: true }) + }) + + it('fails boot loud when the document exists but cannot be read', async () => { + const dir = await tempDir() + const path = join(dir, 'occupied') + await mkdir(path) + const ctx = new Context() + await expect(ctx.plugin(CredentialsLocal, { path, watch: false })).rejects.toThrow() + }) +}) + +describe('line-editing writes', () => { + it('appends a missing key to a fresh 0600 document and emits the commit', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + const ctx = await boot({ path, watch: false }) + const seen = updates(ctx) + await ctx.credentials.set(KEY, 'sk-fresh') + expect(await readFile(path, 'utf8')).toBe('DSH_CRED_TEST=sk-fresh\n') + expect((await stat(path)).mode & 0o777).toBe(0o600) + expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'sk-fresh', source: 'file' }) + expect(seen).toEqual([KEY]) + }) + + it('rewrites one line in place, preserving every other byte and dropping duplicates', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + await writeFile(path, '# deployment notes\nFIRST=one\n\nDSH_CRED_TEST=old\nTRAILING=x\nDSH_CRED_TEST=older') + const ctx = await boot({ path, watch: false }) + await ctx.credentials.set(KEY, 'new value!') + expect(await readFile(path, 'utf8')).toBe('# deployment notes\nFIRST=one\n\nDSH_CRED_TEST=\'new value!\'\nTRAILING=x\n') + }) + + it('quotes hostile values so they round-trip through a fresh provider', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + const ctx = await boot({ path, watch: false }) + const singleQuoted = 'with "quote", back\\slash and space' + const doubleQuoted = "it's got an apostrophe" + await ctx.credentials.set(KEY, singleQuoted) + await ctx.credentials.set(OTHER, doubleQuoted) + const reread = await boot({ path, watch: false }) + expect(await reread.credentials.resolve(KEY)).toEqual({ value: singleQuoted, source: 'file' }) + expect(await reread.credentials.resolve(OTHER)).toEqual({ value: doubleQuoted, source: 'file' }) + }) + + it('fails loud on values no .env quoting style reads back verbatim', async () => { + const dir = await tempDir() + const ctx = await boot({ path: join(dir, '.env'), watch: false }) + await expect(ctx.credentials.set(KEY, 'line one\nline two')).rejects.toThrow(/control characters/) + await expect(ctx.credentials.set(KEY, 'both \' and "')).rejects.toThrow(/mixes quoting/) + }) + + it('unsets only the owning line and keeps an absent unset silent', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + await writeFile(path, '# keep\nDSH_CRED_TEST=gone\nDSH_CRED_OTHER=stays\n') + const ctx = await boot({ path, watch: false }) + const seen = updates(ctx) + await ctx.credentials.unset(KEY) + expect(await readFile(path, 'utf8')).toBe('# keep\nDSH_CRED_OTHER=stays\n') + await ctx.credentials.unset(KEY) + expect(seen).toEqual([KEY]) + }) + + it('rejects empty values, shadowed writes, and multi-line entries', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + await writeFile(path, 'DSH_CRED_TEST="line one\nline two"\n') + const ctx = await boot({ path, watch: false }) + + await expect(ctx.credentials.set(KEY, '')).rejects.toThrow(/empty value/) + await expect(ctx.credentials.set(KEY, 'next')).rejects.toThrow(/multi-line/) + await expect(ctx.credentials.unset(KEY)).rejects.toThrow(/multi-line/) + + vi.stubEnv('DSH_CRED_TEST', 'shadowing') + await expect(ctx.credentials.set(KEY, 'next')).rejects.toThrow(/shadowed/) + await expect(ctx.credentials.unset(KEY)).rejects.toThrow(/shadowed/) + }) + + it('leaves an empty document after unsetting the only entry', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + await writeFile(path, 'DSH_CRED_TEST=only\n') + const ctx = await boot({ path, watch: false }) + await ctx.credentials.unset(KEY) + expect(await readFile(path, 'utf8')).toBe('') + }) + + it('chains past a rejected write so one bad value cannot poison the queue', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + const ctx = await boot({ path, watch: false }) + const bad = expect(ctx.credentials.set(KEY, 'both \' and "')).rejects.toThrow(/mixes quoting/) + const good = ctx.credentials.set(OTHER, 'lands') + await bad + await good + expect(await readFile(path, 'utf8')).toBe('DSH_CRED_OTHER=lands\n') + }) + + it('serializes concurrent writes so both land in the one document', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + const ctx = await boot({ path, watch: false }) + await Promise.all([ + ctx.credentials.set(KEY, 'one'), + ctx.credentials.set(OTHER, 'two'), + ]) + expect(await readFile(path, 'utf8')).toBe('DSH_CRED_TEST=one\nDSH_CRED_OTHER=two\n') + }) + + it('refuses writes after disposal', async () => { + const dir = await tempDir() + const ctx = new Context() + const fiber = ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false }) + await fiber + // Capture the handle first: disposal also removes the ctx.credentials service. + const service = ctx.credentials + await fiber.dispose() + await expect(service.set(KEY, 'late')).rejects.toThrow(/disposed/) + }) +}) + +describe('real hot reload', () => { + it('publishes external edits, replaces the snapshot wholesale, and suppresses self-writes', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + // Watching starts on an existing document: creation racing watcher setup + // is a chokidar readiness gap, not the reload contract under test. + await writeFile(path, 'DSH_CRED_TEST=boot\n') + const ctx = await boot({ path, debounceMs: 10 }) + const seen = updates(ctx) + + await writeFile(path, 'DSH_CRED_TEST=live\nDSH_CRED_OTHER=extra\n') + await vi.waitFor(async () => { + expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'live', source: 'file' }) + }) + + // Wholesale replacement: an entry deleted on disk never lingers in memory. + await writeFile(path, 'DSH_CRED_TEST=live\n') + await vi.waitFor(async () => { + expect(await ctx.credentials.resolve(OTHER)).toBeUndefined() + }) + + const before = seen.length + await ctx.credentials.set(KEY, 'self-written') + await new Promise(resolvePause => setTimeout(resolvePause, 200)) + // Exactly the committed write's own event: the watcher echo of our own + // content is recognized by the text cache and publishes nothing extra. + expect(seen.length).toBe(before + 1) + expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'self-written', source: 'file' }) + }) +}) diff --git a/packages/credentials/credentials-local/tests/review-fixes.spec.ts b/packages/credentials/credentials-local/tests/review-fixes.spec.ts new file mode 100644 index 0000000000..7583cf0813 --- /dev/null +++ b/packages/credentials/credentials-local/tests/review-fixes.spec.ts @@ -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> = [] + +afterEach(async () => { + while (cleanups.length > 0) await cleanups.pop()!() +}) + +async function tempDir(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'dsh-cred-review-')) + cleanups.push(() => rm(dir, { recursive: true, force: true })) + return dir +} + +async function boot(config: ConstructorParameters[1]): Promise { + 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' }) + }) +}) diff --git a/packages/credentials/credentials-local/tests/watcher.spec.ts b/packages/credentials/credentials-local/tests/watcher.spec.ts new file mode 100644 index 0000000000..6ff53252cf --- /dev/null +++ b/packages/credentials/credentials-local/tests/watcher.spec.ts @@ -0,0 +1,223 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import { chmod, mkdtemp, rm, 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' + +// chokidar is the nondeterministic OS boundary: faking it lets these tests +// drive the event pipeline (error events, races with unreadable files) +// deterministically. Real end-to-end watching stays covered by local.spec.ts. +vi.mock('chokidar', async () => { + const { EventEmitter } = await import('node:events') + class FakeWatcher extends EventEmitter { + close = vi.fn(() => Promise.resolve()) + } + const instances: Array<{ path: string; options: unknown; watcher: InstanceType }> = [] + return { + watch: vi.fn((path: string, options: unknown) => { + const watcher = new FakeWatcher() + instances.push({ path, options, watcher }) + return watcher + }), + __instances: instances, + } +}) + +interface FakeChokidar { + __instances: Array<{ + path: string + options: { awaitWriteFinish: { stabilityThreshold: number; pollInterval: number } } + watcher: import('node:events').EventEmitter + }> +} + +async function fakeInstances(): Promise { + const chokidar = await import('chokidar') as unknown as FakeChokidar + return chokidar.__instances +} + +const KEY = credentialRef('DSH_CRED_PIPE') + +const cleanups: Array<() => Promise> = [] + +afterEach(async () => { + while (cleanups.length > 0) await cleanups.pop()!() + ;(await fakeInstances()).length = 0 +}) + +async function tempDir(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'dsh-credentials-watch-')) + cleanups.push(() => rm(dir, { recursive: true, force: true })) + return dir +} + +async function boot(config: ConstructorParameters[1]): Promise { + const ctx = new Context() + const fiber = ctx.plugin(CredentialsLocal, config) + cleanups.push(async () => { + await fiber.dispose() + }) + await fiber + return ctx +} + +describe('watcher pipeline', () => { + it('clamps the write-settle poll interval for a zero debounce', async () => { + const dir = await tempDir() + await boot({ path: join(dir, '.env'), debounceMs: 0 }) + const [instance] = await fakeInstances() + expect(instance!.options.awaitWriteFinish).toEqual({ stabilityThreshold: 0, pollInterval: 1 }) + }) + + it('survives a watcher error and keeps publishing later edits', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + const ctx = await boot({ path, debounceMs: 5 }) + const [instance] = await fakeInstances() + + instance!.watcher.emit('error', new Error('watch backend failure')) + expect(await ctx.credentials.resolve(KEY)).toBeUndefined() + + await writeFile(path, 'DSH_CRED_PIPE=arrived\n') + instance!.watcher.emit('all', 'change', path) + await vi.waitFor(async () => { + expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'arrived', source: 'file' }) + }) + }) + + it('keeps the last good snapshot when the file turns unreadable at runtime', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + await writeFile(path, 'DSH_CRED_PIPE=good\n') + const ctx = await boot({ path, debounceMs: 5 }) + + await chmod(path, 0o000) + cleanups.push(() => chmod(path, 0o600)) + const [instance] = await fakeInstances() + instance!.watcher.emit('all', 'change', path) + // The warn-and-keep path is asynchronous; give the serialized refresh a turn. + await new Promise(resolve => setTimeout(resolve, 50)) + expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'good', source: 'file' }) + }) + + it('keeps the reload queue alive after an invariant violation escapes the fan-out', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + const ctx = await boot({ path, debounceMs: 5 }) + let arm = true + ctx.on('credentials/updated', () => { + if (!arm) return + throw Object.assign(new Error('forged relation'), { code: 'INVARIANT' }) + }) + const [instance] = await fakeInstances() + + await writeFile(path, 'DSH_CRED_PIPE=first\n') + instance!.watcher.emit('all', 'change', path) + // The snapshot commits before the fan-out, so the value lands even though + // the listener threw out of the refresh. + await vi.waitFor(async () => { + expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'first', source: 'file' }) + }) + + arm = false + await writeFile(path, 'DSH_CRED_PIPE=second\n') + instance!.watcher.emit('all', 'change', path) + await vi.waitFor(async () => { + expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'second', source: 'file' }) + }) + }) + + it('quiesces the refresh pipeline before dispose completes', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + await writeFile(path, 'DSH_CRED_PIPE=initial\n') + const ctx = new Context() + const fiber = ctx.plugin(CredentialsLocal, { path, debounceMs: 5 }) + await fiber + let disposed = false + let postDisposeCommits = 0 + ctx.on('credentials/updated', () => { + if (disposed) postDisposeCommits += 1 + }) + + await writeFile(path, 'DSH_CRED_PIPE=changed\n') + const [instance] = await fakeInstances() + // Two queued refreshes: dispose interrupts one mid-flight and the other + // before it starts, so both closed guards must hold. + instance!.watcher.emit('all', 'change', path) + instance!.watcher.emit('all', 'change', path) + 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) + }) + + it('empties the snapshot when the document is deleted and emits the removals', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + await writeFile(path, 'DSH_CRED_PIPE=doomed\n') + const ctx = await boot({ path, debounceMs: 5 }) + const seen: string[] = [] + ctx.on('credentials/updated', (ref) => { + seen.push(ref) + }) + + await rm(path) + const [instance] = await fakeInstances() + instance!.watcher.emit('all', 'unlink', path) + await vi.waitFor(async () => { + expect(await ctx.credentials.resolve(KEY)).toBeUndefined() + }) + expect(seen).toEqual([KEY]) + }) + + it('publishes only seam-addressable keys and preserves the rest untouched', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + await writeFile(path, 'BAD-KEY=1\nDSH_CRED_PIPE=a\n') + const ctx = await boot({ path, debounceMs: 5 }) + const seen: string[] = [] + ctx.on('credentials/updated', (ref) => { + seen.push(ref) + }) + + await writeFile(path, 'BAD-KEY=2\nDSH_CRED_PIPE=b\n') + const [instance] = await fakeInstances() + instance!.watcher.emit('all', 'change', path) + await vi.waitFor(async () => { + expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'b', source: 'file' }) + }) + // The dash-named key is preserved file content the seam cannot address: + // its change publishes nothing and breaks nothing. + expect(seen).toEqual([KEY]) + }) + + it('treats an event for a still-absent file as a no-op', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + const ctx = await boot({ path, debounceMs: 5 }) + const [instance] = await fakeInstances() + instance!.watcher.emit('all', 'add', path) + 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' }) + }) + }) +}) diff --git a/packages/credentials/credentials-local/tsconfig.json b/packages/credentials/credentials-local/tsconfig.json new file mode 100644 index 0000000000..3acfbdeffe --- /dev/null +++ b/packages/credentials/credentials-local/tsconfig.json @@ -0,0 +1,33 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../util/atomic-write" + }, + { + "path": "../../util/paths" + }, + { + "path": "../credentials" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/credentials/credentials/README.i18n.yaml b/packages/credentials/credentials/README.i18n.yaml new file mode 100644 index 0000000000..10fe5f0ffe --- /dev/null +++ b/packages/credentials/credentials/README.i18n.yaml @@ -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 packages/credentials/credentials/README.md +README.md: 1c18c4762360ad081227b7097cd82ddab4fcdefc +README.zh.md: 751fb7c1e8326cef91b925c5f8b9f40d92e1bba6 diff --git a/packages/credentials/credentials/README.md b/packages/credentials/credentials/README.md new file mode 100644 index 0000000000..1c18c47623 --- /dev/null +++ b/packages/credentials/credentials/README.md @@ -0,0 +1,48 @@ +# dsh-credentials + +English | [中文](README.zh.md) + +Abstract credential seam (`ctx.credentials`). One doctrine, three consequences: + +**Configuration carries references to secrets, never the secrets.** A settings section or `cordis.yml` entry says `apiKeyEnv: DEEPSEEK_API_KEY`; the value behind that reference lives with a credential provider. So the settings document stays safe to sync and to render in a configuration UI, `describe()` can answer "is this configured, where from, can I write it" without ever holding a value, and rotating a secret touches no configuration file. + +**Consumers resolve per operation.** `resolve(ref)` is called at the start of each operation (the LLM adapters resolve once per model request) and never cached across operations — that read is what makes a changed credential reach the very next request without restarting any plugin. + +**An empty stored value is absent.** Everywhere: `resolve` skips it, `describe` reports it unconfigured. A blank can never masquerade as a configured secret. + +## Surface + +```ts +import type { Context } from 'cordis' +import { credentialRef } from '@deepseek-ai/dsh-credentials' + +declare const ctx: Context + +const ref = credentialRef('DEEPSEEK_API_KEY') // POSIX shell identifier, branded +const hit = await ctx.credentials.resolve(ref) // { value, source } | undefined +const info = await ctx.credentials.describe(ref) // { configured, source?, writable } — never the value +await ctx.credentials.set(ref, 'sk-…') // rejects while a read-only source shadows the ref +await ctx.credentials.unset(ref) // no-op when absent; same shadowing rule +``` + +`credentials/updated (ref)` fires after a committed change to a provider-managed source — a `set`, an `unset`, or an external edit observed in storage. Ambient process-environment changes are not observable and never emit. Consumers do not need the event (they re-resolve per operation); it exists for configuration UIs refreshing a "configured" badge. + +The shadowing rule on `set`/`unset` is deliberate fail-loud: when a read-only source (the live process environment, in the local provider) currently supplies the reference, a write would appear to succeed while resolution keeps returning the shadowing value — the seam rejects instead, and `describe().writable` lets a UI render the reference read-only up front. + +## Providers + +[`dsh-credentials-local`](../credentials-local/README.md) layers the live process environment over a `$DSH_HOME/.env` file. The seam shape leaves room for keyring-, helper-command-, and KMS-backed providers; a remote settings provider never needs to carry secrets. + +## Model Experience + +Indirectly, through the consuming LLM adapters: a resolved value authorizes their provider requests, and the adapter owns every model-visible surface. + +#### KV Cache effect + +No direct invalidation; credentials never enter a request prefix. + +## Known Limitations and Deferred Work + +- **No enumeration** — the seam answers questions about references it is given; configuration surfaces learn the references from settings schemas, so a `list()` has no current consumer. +- **References are environment-variable-shaped** — one flat POSIX-identifier namespace until a provider needs richer addressing. +- **Process-environment changes are invisible** — no event can fire for them; a UI only re-reads `describe()` on its own navigation. diff --git a/packages/credentials/credentials/README.zh.md b/packages/credentials/credentials/README.zh.md new file mode 100644 index 0000000000..751fb7c1e8 --- /dev/null +++ b/packages/credentials/credentials/README.zh.md @@ -0,0 +1,48 @@ +# dsh-credentials + +[English](README.md) | 中文 + +抽象凭据 seam(`ctx.credentials`)。一条准则,三个推论: + +**配置只携带对机密的引用,绝不携带机密本身。** settings 分节或 `cordis.yml` 条目写 `apiKeyEnv: DEEPSEEK_API_KEY`,引用背后的值归凭据 provider 所有。于是设置文档可以放心同步、放心渲染进配置界面;`describe()` 无需持有值就能回答「配置了吗、来自哪层、能否写入」;轮换机密不触碰任何配置文件。 + +**消费方按操作解析。** `resolve(ref)` 在每个操作开始时调用(LLM 适配器每次模型请求解析一次),绝不跨操作缓存——正是这次读取让改过的凭据无需重启任何插件就作用于下一次请求。 + +**空的存储值等于不存在。**处处如此:`resolve` 跳过它,`describe` 报告未配置。空白永远不会伪装成已配置的机密。 + +## 接口面 + +```ts +import type { Context } from 'cordis' +import { credentialRef } from '@deepseek-ai/dsh-credentials' + +declare const ctx: Context + +const ref = credentialRef('DEEPSEEK_API_KEY') // POSIX shell identifier, branded +const hit = await ctx.credentials.resolve(ref) // { value, source } | undefined +const info = await ctx.credentials.describe(ref) // { configured, source?, writable } — never the value +await ctx.credentials.set(ref, 'sk-…') // rejects while a read-only source shadows the ref +await ctx.credentials.unset(ref) // no-op when absent; same shadowing rule +``` + +`credentials/updated (ref)` 在 provider 管理的来源发生已提交变更后触发——`set`、`unset` 或在存储中观察到的外部编辑。进程环境变量的变化不可观测,永不触发。消费方不需要该事件(它们按操作重新解析);它服务于配置界面刷新「已配置」徽标。 + +`set`/`unset` 的遮蔽规则是刻意的响亮失败:当只读来源(本地 provider 中即活跃进程环境)正在提供该引用时,写入会表面成功而解析仍返回遮蔽值——seam 选择直接拒绝,并通过 `describe().writable` 让界面提前把该引用渲染为只读。 + +## Providers + +[`dsh-credentials-local`](../credentials-local/README.md) 把活跃进程环境叠加在 `$DSH_HOME/.env` 文件之上。seam 形状为 keyring、辅助命令、KMS 后端的 provider 留好了位置;远端 settings provider 永远不必携带机密。 + +## Model Experience + +经由消费它的 LLM 适配器间接生效:解析出的值为适配器的提供方请求授权,每个模型可见面都归适配器所有。 + +#### KV Cache effect + +无直接失效;凭据绝不进入请求前缀。 + +## Known Limitations and Deferred Work + +- **不提供枚举**——seam 只回答被问到的引用;配置界面从 settings schema 得知引用集合,`list()` 没有当前消费方。 +- **引用限定为环境变量形状**——在有 provider 需要更丰富寻址前,保持单一扁平的 POSIX 标识符命名空间。 +- **进程环境变化不可见**——不可能为其发事件;界面只能在自身导航时重新读取 `describe()`。 diff --git a/packages/credentials/credentials/package.json b/packages/credentials/credentials/package.json new file mode 100644 index 0000000000..d907b0a1bb --- /dev/null +++ b/packages/credentials/credentials/package.json @@ -0,0 +1,39 @@ +{ + "name": "@deepseek-ai/dsh-credentials", + "description": "Abstract credential seam (ctx.credentials): settings carry references to secrets, providers own the values", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-brand": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/credentials/credentials/src/index.ts b/packages/credentials/credentials/src/index.ts new file mode 100644 index 0000000000..b640b42881 --- /dev/null +++ b/packages/credentials/credentials/src/index.ts @@ -0,0 +1,162 @@ +/** + * Credential seam (`ctx.credentials`). Settings and composition files carry + * *references* to secrets — environment-variable names — while providers own + * the actual values and their storage. Consumers resolve a reference once per + * operation, so a changed credential reaches the next operation without any + * plugin restart, and configuration surfaces describe a reference without + * ever seeing its value. + * @module @deepseek-ai/dsh-credentials + */ + +import { Context, Service } from 'cordis' +import type { Branded } from '@deepseek-ai/dsh-brand' + +/** Nominal reference to one credential: a POSIX-style environment-variable name. */ +export type CredentialRef = Branded<'CredentialRef'> + +const REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/ + +/** + * Brand a raw string as a {@link CredentialRef}. + * @param value - candidate reference; a POSIX shell identifier such as `DEEPSEEK_API_KEY`. + * @returns the branded reference. + */ +export function credentialRef(value: string): CredentialRef { + if (!REF_PATTERN.test(value)) { + throw new TypeError(`credential ref "${value}" must match ${String(REF_PATTERN)}`) + } + return value as CredentialRef +} + +/** One resolved credential value and the source layer that supplied it. */ +export interface ResolvedCredential { + /** The non-empty secret value. */ + value: string + /** Provider-defined source layer id (the local provider uses `env` and `file`). */ + source: string +} + +/** Source and writability facts for one reference, safe for configuration UIs — never the value. */ +export interface CredentialInfo { + /** Whether {@link Credentials.resolve} would currently return a value. */ + configured: boolean + /** Source layer currently supplying the value; absent while unconfigured. */ + source?: string + /** Whether {@link Credentials.set} would currently succeed for this reference. */ + writable: boolean +} + +declare module 'cordis' { + interface Context { + credentials: Credentials + } + + interface Events { + /** + * 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. + * @param ref - the reference whose stored value changed. + * @mode emit + */ + 'credentials/updated'(ref: CredentialRef): void + } +} + +/** + * Abstract credential service. Providers implement the four operations over + * their source layers; one seam-wide rule binds them all: an empty stored + * value is absent everywhere — `resolve` skips it, `describe` reports it + * unconfigured — so a blank never masquerades as a configured secret. + */ +export abstract class Credentials extends Service { + constructor(ctx: Context) { + super(ctx, 'credentials') + } + + /** + * Resolve one reference to its current value. Resolution is per call: + * consumers re-resolve at each operation and must not cache across + * operations — that per-operation read is what makes a changed credential + * reach the next operation without a restart. + * @param ref - the reference to resolve. + * @returns the value and its source, or `undefined` while unconfigured. + */ + abstract resolve(ref: CredentialRef): Promise + + /** + * Describe one reference for configuration surfaces without exposing the + * value. + * @param ref - the reference to describe. + * @returns configured state, supplying source, and writability. + */ + abstract describe(ref: CredentialRef): Promise + + /** + * Durably store one value in the provider-managed writable source. Rejects + * while a read-only source shadows the reference — the write would appear + * to succeed while resolution keeps returning the shadowing value — and + * rejects an empty value (use {@link unset}). + * @param ref - the reference to store. + * @param value - the non-empty secret value. + */ + abstract set(ref: CredentialRef, value: string): Promise + + /** + * Remove one reference from the provider-managed writable source; removing + * an absent reference is a no-op. Rejects while a read-only source shadows + * the reference, like {@link set}. + * @param ref - the reference to remove. + */ + abstract unset(ref: CredentialRef): Promise + + /* 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).then === 'function') { + void Promise.resolve(returned as PromiseLike).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 diff --git a/packages/credentials/credentials/src/invariant.ts b/packages/credentials/credentials/src/invariant.ts new file mode 100644 index 0000000000..23c2dda45b --- /dev/null +++ b/packages/credentials/credentials/src/invariant.ts @@ -0,0 +1,38 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-credentials`. + * @module @deepseek-ai/dsh-credentials/invariant + */ + +import type { Context } from 'cordis' +import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-credentials' + +/** Cordis companion plugin name. */ +export const name = 'credentials-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * Install the commit-event lifecycle contract: `credentials/updated` names a + * committed provider-source change, so it can only fire while a credentials + * service is live — an emission after disposal means a provider leaked work + * past its teardown quiescence. The value relation itself (`describe` + * agreeing with `resolve`) is asynchronous provider I/O and stays pinned by + * each provider's own suite. + */ +const install: InvariantInstaller = (ctx: Context, fail: InvariantFailure) => { + ctx.on('credentials/updated', (ref) => { + if (ctx.get('credentials') === undefined) { + fail(`credentials/updated for "${ref}" emitted without a live credentials service`) + } + }) +} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/credentials/credentials/tests/credentials.spec.ts b/packages/credentials/credentials/tests/credentials.spec.ts new file mode 100644 index 0000000000..9b4cf7b1e8 --- /dev/null +++ b/packages/credentials/credentials/tests/credentials.spec.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { credentialRef } from '../src/index.ts' +import type { CredentialRef } from '../src/index.ts' +import { MemoryCredentials } from './memory.ts' + +const REF = credentialRef('DEEPSEEK_API_KEY') + +async function boot(seed: Record = {}): Promise { + const ctx = new Context() + await ctx.plugin(MemoryCredentials, seed) + return ctx +} + +describe('credentialRef', () => { + it('brands POSIX shell identifiers', () => { + expect(credentialRef('DEEPSEEK_API_KEY')).toBe('DEEPSEEK_API_KEY') + expect(credentialRef('_private')).toBe('_private') + expect(credentialRef('lower_case9')).toBe('lower_case9') + }) + + it('rejects every other shape', () => { + for (const invalid of ['', '9LEADING', 'WITH-DASH', 'WITH SPACE', 'ns:key']) { + expect(() => credentialRef(invalid)).toThrow(TypeError) + } + }) +}) + +describe('the credentials seam through the memory provider', () => { + it('mounts as ctx.credentials and resolves a seeded reference with its source', async () => { + const ctx = await boot({ DEEPSEEK_API_KEY: 'sk-seeded' }) + expect(await ctx.credentials.resolve(REF)).toEqual({ value: 'sk-seeded', source: 'memory' }) + expect(await ctx.credentials.describe(REF)).toEqual({ configured: true, source: 'memory', writable: true }) + }) + + it('treats an empty stored value as absent everywhere', async () => { + const ctx = await boot({ DEEPSEEK_API_KEY: '' }) + expect(await ctx.credentials.resolve(REF)).toBeUndefined() + expect(await ctx.credentials.describe(REF)).toEqual({ configured: false, writable: true }) + }) + + it('stores through set, removes through unset, and emits the committed change', async () => { + const ctx = await boot() + const events: CredentialRef[] = [] + ctx.on('credentials/updated', ref => void events.push(ref)) + + await ctx.credentials.set(REF, 'sk-live') + expect(await ctx.credentials.resolve(REF)).toEqual({ value: 'sk-live', source: 'memory' }) + await ctx.credentials.unset(REF) + expect(await ctx.credentials.resolve(REF)).toBeUndefined() + expect(events).toEqual([REF, REF]) + }) + + it('rejects an empty set and keeps an absent unset silent', async () => { + const ctx = await boot() + const events: CredentialRef[] = [] + ctx.on('credentials/updated', ref => void events.push(ref)) + + await expect(ctx.credentials.set(REF, '')).rejects.toThrow(/empty value/) + await ctx.credentials.unset(REF) + expect(events).toEqual([]) + }) + + it('removes the service with its fiber', async () => { + const ctx = new Context() + const fiber = await ctx.plugin(MemoryCredentials) + expect(ctx.get('credentials')).toBeDefined() + await fiber.dispose() + expect(ctx.get('credentials')).toBeUndefined() + }) +}) diff --git a/packages/credentials/credentials/tests/invariant.spec.ts b/packages/credentials/credentials/tests/invariant.spec.ts new file mode 100644 index 0000000000..dccde4843f --- /dev/null +++ b/packages/credentials/credentials/tests/invariant.spec.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import InvariantService from '@deepseek-ai/dsh-invariants' +import { credentialRef } from '../src/index.ts' +import * as CredentialsInvariant from '../src/invariant.ts' +import { MemoryCredentials } from './memory.ts' + +const REF = credentialRef('DEEPSEEK_API_KEY') + +describe('credentials invariant companion', () => { + it('accepts a committed change emitted by a live service', async () => { + const ctx = new Context() + await ctx.plugin(InvariantService) + await ctx.plugin(CredentialsInvariant) + await ctx.plugin(MemoryCredentials) + + await expect(ctx.credentials.set(REF, 'sk-live')).resolves.toBeUndefined() + }) + + it('fails an update event emitted without a live service', async () => { + const ctx = new Context() + await ctx.plugin(InvariantService) + await ctx.plugin(CredentialsInvariant) + + expect(() => { ctx.emit('credentials/updated', REF) }).toThrow(/invariant violated by "@deepseek-ai\/dsh-credentials"/) + }) + + it('reserves the package name against duplicate registration', async () => { + const ctx = new Context() + await ctx.plugin(InvariantService) + await ctx.plugin(CredentialsInvariant) + + expect(() => { + ctx.invariants.register('@deepseek-ai/dsh-credentials', () => {}) + }).toThrow(/already registered/) + }) +}) diff --git a/packages/credentials/credentials/tests/memory.ts b/packages/credentials/credentials/tests/memory.ts new file mode 100644 index 0000000000..dc1ed77a06 --- /dev/null +++ b/packages/credentials/credentials/tests/memory.ts @@ -0,0 +1,49 @@ +import type { Context } from 'cordis' +import { Credentials } from '../src/index.ts' +import type { CredentialInfo, CredentialRef, ResolvedCredential } from '../src/index.ts' + +/** + * In-memory credentials provider for interface and consumer tests: one + * always-writable `memory` source seeded from plugin config. + */ +export class MemoryCredentials extends Credentials { + private readonly store = new Map() + + constructor(ctx: Context, seed: Record = {}) { + super(ctx) + for (const [key, value] of Object.entries(seed)) this.store.set(key, value) + } + + override resolve(ref: CredentialRef): Promise { + const value = this.store.get(ref) + return Promise.resolve(value === undefined || value.length === 0 + ? undefined + : { value, source: 'memory' }) + } + + override describe(ref: CredentialRef): Promise { + const value = this.store.get(ref) + const configured = value !== undefined && value.length > 0 + return Promise.resolve({ + configured, + ...configured ? { source: 'memory' } : {}, + writable: true, + }) + } + + override set(ref: CredentialRef, value: string): Promise { + if (value.length === 0) { + return Promise.reject(new Error('memory credentials: an empty value cannot be stored; use unset')) + } + this.store.set(ref, value) + this.ctx.emit('credentials/updated', ref) + return Promise.resolve() + } + + override unset(ref: CredentialRef): Promise { + if (this.store.delete(ref)) { + this.ctx.emit('credentials/updated', ref) + } + return Promise.resolve() + } +} diff --git a/packages/credentials/credentials/tsconfig.json b/packages/credentials/credentials/tsconfig.json new file mode 100644 index 0000000000..5bc7a9fcf5 --- /dev/null +++ b/packages/credentials/credentials/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../util/brand" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index 594600e8c3..e02f994fef 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/README.i18n.yaml @@ -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: 7a2314ea2ca0fb6606310a4961fcbc658240a7b8 -README.zh.md: d73145f8b8a32d8a515b6b4c0e916f0a11cd4771 +README.md: ab44b61e300ca65cc4dd3507ad7262cd08edcfce +README.zh.md: 4ecaf361fdb396f9f8079476240b5e9353a73f5e diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 7a2314ea2c..ab44b61e30 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -14,8 +14,9 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' config: - apiKey: !!js process.env.DEEPSEEK_API_KEY # or rely on the env fallback - baseURL: !!js process.env.DEEPSEEK_BASE_URL # default: https://api.deepseek.com + apiKeyEnv: DEEPSEEK_API_KEY # default; resolved per request via ctx.credentials, then the environment + # apiKey: … # literal escape hatch; prefer the reference so no secret enters this file + baseURL: https://api.deepseek.com # optional; $DEEPSEEK_BASE_URL then the public API when omitted thinking: enabled # optional; provider default is enabled reasoningEffort: high # optional; off | high | max — omitted ⇒ high streamIdleTimeoutMs: 300000 # optional; positive finite Node timer delay; five-minute default @@ -44,6 +45,15 @@ The same exact-model result exposes ordered `off`, `high`, and `max` efforts und `streamIdleTimeoutMs` bounds each outstanding provider read, including the initial `fetch`, without counting time the consumer spends between chunks. One stable abort signal reaches the request and body reader for the whole call; expiry stops the transport and throws `LlmError('TIMEOUT')`, while an earlier caller abort throws `LlmError('ABORTED')`. The adapter makes exactly one provider request per `stream()` call; it registers the configured policy as provider metadata, and `dsh-llm-retry` separately executes it at durable agent-step boundaries. +## Dynamic configuration (settings + credentials) + +Connection facts are not frozen at load. `resolveAdapterOptions` is the one explicit resolve step from raw config to validated facts, and the adapter re-reads them through a thunk **once per operation**: base URL, catalog, request defaults, and idle budget all take effect on the next request, while an in-flight stream keeps the facts it started with. Two optional seams feed that thunk: + +- **`ctx.settings`** — the plugin registers the `llm-deepseek` namespace with this same `Config` schema and its `cordis.yml` entry as the composition `base`, so a `llm-deepseek:` section in the user settings document overrides any field without a restart. Without a mounted settings service the entry config alone drives the adapter, unchanged. A live settings snapshot that passes the schema but fails a beyond-schema bound (a duplicate catalog id, a broken thinking/effort pair) keeps the last good facts and logs the failure; the entry config itself still fails plugin load. +- **`ctx.credentials`** — the API key resolves per stream call, from the *same* resolved snapshot that supplies the endpoint: a 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')` always reports the current policy. + ## App attribution Every request carries the shared attribution header from dsh-llm's `attributionHeaders()` - the mandatory `User-Agent` baseline identifying the harness (see [dsh-llm § App attribution](../llm/README.md#app-attribution-attributionts)). Direct DeepSeek requests and OpenAI-compatible gateway requests get no provider-specific app-attribution headers under this adapter contract; OpenRouter app attribution is deferred to a future explicit OpenRouter adapter or mode. A request whose `GenerateOptions.purpose` is `compaction` (dsh-compact-basic's auxiliary summarization call) additionally carries `x-deepseek-harness-compact: 1`, so the host can separate compaction traffic from conversation requests. @@ -62,7 +72,7 @@ Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `QUOTA` ## Testing -Unit suites run against a local `node:http` mock SSE server (no network), including dynamic `high`/`off`/`max` selection, structured HTTP facts, malformed/truncated streams, caller abort, connection failure, and proof that idle timeout aborts the actual body. Real-API coverage lives in `tests/adapter.e2e.ts` (`pnpm run test:e2e`, key-gated): V4 Flash + V4 Pro across thinking enabled/disabled and both official effort levels, including the thinking+tools round trip with reasoning passback. +Unit suites run against a local `node:http` mock SSE server (no network), including dynamic `high`/`off`/`max` selection, structured HTTP facts, malformed/truncated streams, caller abort, connection failure, and proof that idle timeout aborts the actual body. `tests/dynamic-config.spec.ts` drives real settings-local and credentials-local providers (next-request base-URL/key pickup, literal precedence, keyless onboarding, last-good snapshots, retry-policy re-registration), and `tests/loader-composition.spec.ts` boots the full chain from a test-only `cordis.yml` through the actual Loader and edits `settings.yaml`/`.env` on disk. Real-API coverage lives in `tests/adapter.e2e.ts` (`pnpm run test:e2e`, key-gated): V4 Flash + V4 Pro across thinking enabled/disabled and both official effort levels, including the thinking+tools round trip with reasoning passback and a request whose key exists only in a credentials-local document. ## Model Experience @@ -96,6 +106,8 @@ Loop-retained response blocks append to the next request and preserve its earlie ## Known Limitations and Deferred Work +- **A settings `models` list replaces the composition list wholesale** — settings-layer merging is per-field, and arrays are one field; per-entry catalog merging would need a keyed shape. +- **`Config.apiKey` is schema-tagged `role('secret')` but not yet masked anywhere** — the settings `describe()` envelope returns values verbatim; the wire/UI layer that must redact secret-role fields ships with the settings RPC surface. - **`tool_choice` is not mapped** — not part of the core vocabulary (MVP cut, shared with the pi-ai twin). - **Requests use raw `fetch`, not `@cordisjs/plugin-http`** — no shared proxy/interception configuration; adoption is deferred until a second adapter wants it (`TODO(http)`). - **Serialization flattens user and tool-result content to text blocks** — plugin-added block types are skipped, and empty tool output crosses the wire as the literal `(no output)`. diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index d73145f8b8..4ecaf361fd 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -14,8 +14,9 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' config: - apiKey: !!js process.env.DEEPSEEK_API_KEY # or rely on the env fallback - baseURL: !!js process.env.DEEPSEEK_BASE_URL # default: https://api.deepseek.com + apiKeyEnv: DEEPSEEK_API_KEY # default; resolved per request via ctx.credentials, then the environment + # apiKey: … # literal escape hatch; prefer the reference so no secret enters this file + baseURL: https://api.deepseek.com # optional; $DEEPSEEK_BASE_URL then the public API when omitted thinking: enabled # optional; provider default is enabled reasoningEffort: high # optional; off | high | max — omitted ⇒ high streamIdleTimeoutMs: 300000 # optional; positive finite Node timer delay; five-minute default @@ -44,6 +45,15 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: `streamIdleTimeoutMs` 会限制每次未完成提供方读取,包括初始 `fetch`,但不计入消费方在分片间花费的时间。同一个稳定的 abort 信号会在整个调用期间传递给请求与 body reader;过期会停止传输并抛出 `LlmError('TIMEOUT')`,较早的调用方 abort 则抛出 `LlmError('ABORTED')`。适配器每次 `stream()` 调用恰好发起一次提供方请求;它把已配置策略注册为提供方元数据,再由 `dsh-llm-retry` 在持久化的 agent(智能体)步骤边界单独执行该策略。 +## 动态配置(settings + credentials) + +连接事实不在加载时冻结。`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 时——读取原始环境变量。由于凭据事实与连接事实同行,被 resolver 拒绝的 settings 快照既不贡献自己的端点,也不贡献自己的密钥:整个先前世代继续服务。任何地方都没有密钥的请求以 `MISSING_CREDENTIAL` 失败,并点名每个配置入口,同时路由保持注册、catalog 保持可浏览——首次运行的上手流程就是「浏览模型、存入密钥、再次发起提示」,中间无需任何重启。 + +唯一在注册期捕获的事实是重试策略:其解析值变化时,插件原地重新注册该路由(同一适配器实例、一个同步区段),因此 `ctx.llm.providerRetryPolicy('deepseek')` 始终报告当前策略。 + ## 应用归因 每个请求都携带 dsh-llm `attributionHeaders()` 的共享归因标头,即用于识别 harness 的必需 `User-Agent` 基线(见 [dsh-llm § 应用归因](../llm/README.md#app-attribution-attributionts))。在该适配器契约(adapter contract)下,直接 DeepSeek 请求与 OpenAI 兼容 gateway 请求都不会获得提供方特定应用归因标头;OpenRouter 应用归因暂缓到未来的显式 OpenRouter 适配器或模式。`GenerateOptions.purpose` 为 `compaction` 的请求(dsh-compact-basic 的辅助摘要调用)还会携带 `x-deepseek-harness-compact: 1`,让宿主可以将压缩流量与会话请求分开。 @@ -62,7 +72,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: ## 测试 -单元套件使用本地 `node:http` mock SSE 服务器(无网络),覆盖动态 `high`/`off`/`max` 选择、结构化 HTTP 事实、格式错误/截断流、调用方 abort、连接失败,以及 idle 超时确实会 abort 实际 body 的证明。真实 API 覆盖位于 `tests/adapter.e2e.ts`(`pnpm run test:e2e`,需有 key 才会运行):V4 Flash + V4 Pro,覆盖思考启用/禁用与两种官方 effort 级别,包括思考 + 工具往返与推理回传。 +单元套件使用本地 `node:http` mock SSE 服务器(无网络),覆盖动态 `high`/`off`/`max` 选择、结构化 HTTP 事实、格式错误/截断流、调用方 abort、连接失败,以及 idle 超时确实会 abort 实际 body 的证明。`tests/dynamic-config.spec.ts` 驱动真实的 settings-local 与 credentials-local provider(下一请求即生效的 base-URL/密钥拾取、字面值优先、无密钥上手、最后可用快照、重试策略重注册),`tests/loader-composition.spec.ts` 则从仅测试用的 `cordis.yml` 出发,经真实 Loader 拉起完整链路,并在磁盘上编辑 `settings.yaml`/`.env`。真实 API 覆盖位于 `tests/adapter.e2e.ts`(`pnpm run test:e2e`,需有 key 才会运行):V4 Flash + V4 Pro,覆盖思考启用/禁用与两种官方 effort 级别,包括思考 + 工具往返与推理回传,以及密钥仅存在于 credentials-local 文档中的请求。 ## 模型体验 @@ -96,6 +106,8 @@ loop 保留的响应块会追加到下一个请求,并保留其较早可复用 ## 已知限制与暂缓事项 +- **settings 的 `models` 列表会整体替换组合列表**:settings 层按字段合并,而数组是单个字段;按条目合并 catalog 需要带键的形状。 +- **`Config.apiKey` 已在 schema 中标注 `role('secret')`,但尚未在任何地方脱敏**:settings 的 `describe()` 信封原样返回值;负责对 secret 角色字段脱敏的 wire/UI 层将随 settings RPC 面一起交付。 - **未映射 `tool_choice`**:它不属于核心词汇(MVP 取舍,与 pi-ai twin 共享)。 - **请求使用原始 `fetch`,而非 `@cordisjs/plugin-http`**:没有共享 proxy/拦截配置;采用暂缓到第二个适配器需要该功能时(`TODO(http)`)。 - **序列化会将 user 与工具结果内容展平为文本块**:会跳过插件添加的块类型,空工具输出会以字面 `(no output)` 通过协议发送。 diff --git a/packages/llm/llm-deepseek/package.json b/packages/llm/llm-deepseek/package.json index d233ef7764..2c39e2d920 100644 --- a/packages/llm/llm-deepseek/package.json +++ b/packages/llm/llm-deepseek/package.json @@ -27,8 +27,10 @@ ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-credentials": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-settings": "^0.0.1", "@deepseek-ai/dsh-timeout": "^0.0.1", "cordis": "^4.0.0-rc.7" }, @@ -37,8 +39,10 @@ "schemastery": "^3.18.0" }, "devDependencies": { + "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index ff5ce9bf72..a4b02a3e39 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -1,21 +1,24 @@ /** * `DeepSeekAdapter`: fetch + SSE against a DeepSeek (OpenAI-compatible) - * chat-completions endpoint, emitting harness StreamChunks. + * chat-completions endpoint, emitting harness StreamChunks. The adapter is + * transport-only: connection facts arrive through a thunk resolved once per + * operation and the bearer token through a per-request resolver, so the + * registering plugin owns validation, layering, and credential policy. * * @module dsh-llm-deepseek/adapter */ -import { attributionHeaders, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, LlmAdapter, LlmError, ProviderRequestId, QUOTA_EXCEEDED_CODE, ReasoningEffortId, resolveRetryPolicy } from '@deepseek-ai/dsh-llm' +import { attributionHeaders, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, LlmAdapter, LlmError, ProviderRequestId, QUOTA_EXCEEDED_CODE, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, LlmModelInfo, LlmProviderInfo, LlmResolvedModelInfo, ResolvedRetryPolicy, - RetryPolicyConfig, StreamChunk, } from '@deepseek-ai/dsh-llm' -import { idleWatchdog, MAX_TIMER_DELAY_MS, timeoutOf } from '@deepseek-ai/dsh-timeout' +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' import { parseSse } from './sse.ts' @@ -34,22 +37,46 @@ export interface DeepSeekCatalogModel { contextWindow?: number } -/** Constructor options for {@link DeepSeekAdapter}; the plugin's `apply` resolves them from Config + environment. */ -export interface DeepSeekAdapterOptions { - /** Bearer token sent in the `authorization` header on every request. */ - apiKey: string +/** + * Validated connection facts for one operation. The plugin's + * `resolveAdapterOptions` is the one explicit resolve step producing this + * shape; the adapter trusts it and re-reads it per operation, which is what + * makes a configuration change reach the next request without re-registration. + */ +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 + defaults: RequestDefaults /** Positive context capacity used when the selected model has no exact value. */ defaultContextWindow?: number /** Advisory models exposed to discovery consumers; requests remain unrestricted. */ - models?: readonly DeepSeekCatalogModel[] + models: readonly DeepSeekCatalogModel[] /** Maximum provider idle time while one stream read is outstanding. */ - streamIdleTimeoutMs?: number - /** Provider-owned model-request retry policy; omission uses normal defaults. */ - retryPolicy?: RetryPolicyConfig + streamIdleTimeoutMs: number + /** Provider-owned model-request retry policy, already resolved. */ + retryPolicy: ResolvedRetryPolicy +} + +/** Constructor options for {@link DeepSeekAdapter}: the two resolution seams the plugin owns. */ +export interface DeepSeekAdapterOptions { + /** Current validated connection facts; called once per operation. */ + options: () => DeepSeekConnectionOptions + /** + * 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: (connection: DeepSeekConnectionOptions) => Promise } /** Default maximum idle interval while an adapter stream read is outstanding. */ @@ -118,29 +145,8 @@ export function httpErrorCode(status: number, error?: WireError['error']): strin * map to `ABORTED`; the configured per-read idle watchdog maps to `TIMEOUT`. */ export class DeepSeekAdapter extends LlmAdapter { - private readonly streamIdleTimeoutMs: number - private readonly retryPolicy: ResolvedRetryPolicy - - constructor(private readonly options: DeepSeekAdapterOptions) { + constructor(private readonly config: DeepSeekAdapterOptions) { super() - if (options.defaults?.thinking === 'disabled' - && options.defaults.reasoningEffort !== undefined - && options.defaults.reasoningEffort !== 'off') { - throw new Error('llm-deepseek: only reasoningEffort "off" can be configured when thinking is disabled') - } - if (options.defaultContextWindow !== undefined - && (!Number.isInteger(options.defaultContextWindow) || options.defaultContextWindow <= 0)) { - throw new Error('llm-deepseek: defaultContextWindow must be a positive integer') - } - this.streamIdleTimeoutMs = options.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS - if (!Number.isFinite(this.streamIdleTimeoutMs) - || this.streamIdleTimeoutMs <= 0 - || this.streamIdleTimeoutMs > MAX_TIMER_DELAY_MS) { - throw new Error( - `llm-deepseek: streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`, - ) - } - this.retryPolicy = resolveRetryPolicy(options.retryPolicy, 'llm-deepseek: retryPolicy') } override providerInfo(provider: string): LlmProviderInfo { @@ -148,11 +154,11 @@ export class DeepSeekAdapter extends LlmAdapter { } override providerRetryPolicy(_provider: string): ResolvedRetryPolicy { - return this.retryPolicy + return this.config.options().retryPolicy } override listModels(provider: string): Promise { - return Promise.resolve((this.options.models ?? []).map(model => modelInfo(provider, model))) + return Promise.resolve(this.config.options().models.map(model => modelInfo(provider, model))) } override resolveModel( @@ -160,15 +166,16 @@ export class DeepSeekAdapter extends LlmAdapter { model: string, _signal?: AbortSignal, ): Promise { - const configured = this.options.models?.find(entry => entry.id === model) + const connection = this.config.options() + const configured = connection.models.find(entry => entry.id === model) const contextWindow = configured?.contextWindow - ?? this.options.defaultContextWindow + ?? connection.defaultContextWindow return Promise.resolve({ ...configured === undefined ? { provider, id: model, name: model } : modelInfo(provider, configured), ...contextWindow === undefined ? {} : { context: { contextWindow } }, - ...this.options.defaults?.thinking === 'disabled' + ...connection.defaults.thinking === 'disabled' ? { reasoning: { efforts: OFF_ONLY_REASONING_EFFORTS, @@ -178,9 +185,9 @@ export class DeepSeekAdapter extends LlmAdapter { : { reasoning: { efforts: REASONING_EFFORTS, - defaultEffort: this.options.defaults?.reasoningEffort === 'off' + defaultEffort: connection.defaults.reasoningEffort === 'off' ? OFF_REASONING_EFFORT - : this.options.defaults?.reasoningEffort === 'max' + : connection.defaults.reasoningEffort === 'max' ? MAX_REASONING_EFFORT : HIGH_REASONING_EFFORT, }, @@ -189,12 +196,19 @@ export class DeepSeekAdapter extends LlmAdapter { } async * stream(options: GenerateOptions): AsyncIterable { + // 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(connection) const consumer = new AbortController() const upstream = options.signal === undefined ? consumer.signal : AbortSignal.any([options.signal, consumer.signal]) - using watchdog = idleWatchdog(upstream, this.streamIdleTimeoutMs, STREAM_IDLE_TIMEOUT_CODE) - const iterator = this.request(options, watchdog.signal)[Symbol.asyncIterator]() + using watchdog = idleWatchdog(upstream, connection.streamIdleTimeoutMs, STREAM_IDLE_TIMEOUT_CODE) + const iterator = this.request(options, watchdog.signal, connection, apiKey)[Symbol.asyncIterator]() let exhausted = false try { while (true) { @@ -208,7 +222,7 @@ export class DeepSeekAdapter extends LlmAdapter { } catch (error: unknown) { if (timeoutOf(watchdog.signal, STREAM_IDLE_TIMEOUT_CODE) !== undefined) { throw new LlmError( - `DeepSeek stream idle timeout after ${this.streamIdleTimeoutMs}ms`, + `DeepSeek stream idle timeout after ${connection.streamIdleTimeoutMs}ms`, 'TIMEOUT', { cause: error }, ) @@ -217,7 +231,7 @@ export class DeepSeekAdapter extends LlmAdapter { throw new LlmError('DeepSeek request aborted by caller', 'ABORTED', { cause: error }) } if (error instanceof LlmError) throw error - throw new LlmError(`DeepSeek API stream from ${this.options.baseURL} failed`, 'TRANSPORT', { cause: error }) + throw new LlmError(`DeepSeek API stream from ${connection.baseURL} failed`, 'TRANSPORT', { cause: error }) } finally { consumer.abort('DeepSeek stream consumer stopped') if (!exhausted && iterator.return !== undefined) { @@ -230,13 +244,18 @@ export class DeepSeekAdapter extends LlmAdapter { } } - private async * request(options: GenerateOptions, signal: AbortSignal): AsyncIterable { - const body = serializeRequest(options, this.options.defaults ?? {}) + private async * request( + options: GenerateOptions, + signal: AbortSignal, + connection: DeepSeekConnectionOptions, + apiKey: string, + ): AsyncIterable { + const body = serializeRequest(options, connection.defaults) // Prepared outside the try so the TRANSPORT label below covers exactly the // transport boundary, never a serialization failure. const payload = JSON.stringify(body) const headers = { - 'authorization': `Bearer ${this.options.apiKey}`, + 'authorization': `Bearer ${apiKey}`, 'content-type': 'application/json', 'accept': 'text/event-stream', ...attributionHeaders(), @@ -252,7 +271,7 @@ export class DeepSeekAdapter extends LlmAdapter { // outweighs its additional runtime dependencies. let response: Response try { - response = await fetch(`${this.options.baseURL}/chat/completions`, { + response = await fetch(`${connection.baseURL}/chat/completions`, { method: 'POST', headers, body: payload, @@ -266,7 +285,7 @@ export class DeepSeekAdapter extends LlmAdapter { // lives on `cause`. Wrapping with the endpoint and chaining the cause // lets `errorChain` render the full diagnosis at every reporting seam. throw new LlmError( - `DeepSeek API request to ${this.options.baseURL} failed`, + `DeepSeek API request to ${connection.baseURL} failed`, 'TRANSPORT', { cause: error }, ) diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index 00db46d642..6623351774 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -1,41 +1,57 @@ /** - * Register a {@link DeepSeekAdapter} for the `deepseek` provider route on `ctx.llm`. Configuration uses - * Cordis schemastery; pass secrets from environment variables through `cordis.yml` with `!!js`, - * as shown in the package README, rather than reading ad hoc files. + * Register a {@link DeepSeekAdapter} for the `deepseek` provider route on + * `ctx.llm`, with connection facts resolved per request instead of frozen at + * load: the plugin layers its `cordis.yml` entry config under the optional + * `llm-deepseek` user-settings section (`ctx.settings`) and resolves the API + * key through the optional credential seam (`ctx.credentials`), so a changed + * base URL, catalog, or key reaches the very next request without restarting + * anything, while an in-flight stream keeps the facts it started with. The + * one registration-captured fact — the retry policy — re-registers the route + * in place when it changes. * @module @deepseek-ai/dsh-llm-deepseek */ import type { Context } from 'cordis' import z from 'schemastery' -import { RetryPolicySchema } from '@deepseek-ai/dsh-llm' +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 { 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' -import type { DeepSeekCatalogModel } from './adapter.ts' +import type { DeepSeekCatalogModel, DeepSeekConnectionOptions } from './adapter.ts' export { DeepSeekAdapter } from './adapter.ts' -export type { DeepSeekAdapterOptions, DeepSeekCatalogModel } from './adapter.ts' +export type { DeepSeekAdapterOptions, DeepSeekCatalogModel, DeepSeekConnectionOptions } from './adapter.ts' export type { RequestDefaults } from './serialize.ts' export type * from './types.ts' export const name = 'llm-deepseek' 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' + const DEFAULT_MODELS: DeepSeekCatalogModel[] = [ { id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', contextWindow: 256_000 }, { id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', contextWindow: 256_000 }, ] /** - * Plugin config, validated by the same-named schemastery schema. Every field - * is optional in yml: credentials/endpoint fall back to the environment (a - * missing API key fails plugin load, not the first call), omitted thinking - * mode uses the provider default, and omitted reasoning effort resolves to - * `high`. + * Plugin config, validated by the same-named schemastery schema and doubling + * as the `llm-deepseek` settings-section shape. Every field is optional in + * yml: a missing API key resolves through {@link Config.apiKeyEnv} at each + * request (a request without any key fails with `MISSING_CREDENTIAL`, not at + * plugin load), omitted thinking mode uses the provider default, and omitted + * reasoning effort resolves to `high`. */ export interface Config { - /** API key; falls back to $DEEPSEEK_API_KEY. Required one way or the other. */ + /** Literal API key; prefer {@link apiKeyEnv} so no secret enters configuration files. */ apiKey?: string + /** Credential reference (environment-variable name) resolved per request; defaults to `DEEPSEEK_API_KEY`. */ + apiKeyEnv?: string /** Endpoint base; falls back to $DEEPSEEK_BASE_URL, then the public API. */ baseURL?: string /** Deployment thinking policy; `disabled` limits every conversation request to `off`. */ @@ -60,7 +76,8 @@ const catalogModel: z = z.object({ }) export const Config: z = z.object({ - apiKey: z.string(), + apiKey: z.string().role('secret'), + apiKeyEnv: z.string().default(DEFAULT_API_KEY_ENV), baseURL: z.string(), thinking: z.union(['enabled', 'disabled']), reasoningEffort: z.union(['off', 'high', 'max']), @@ -73,6 +90,14 @@ export const Config: z = z.object({ /** Public API default; the internal endpoint comes from $DEEPSEEK_BASE_URL. */ export const PUBLIC_BASE_URL = 'https://api.deepseek.com' +/** + * 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[] { const seen = new Set() @@ -98,20 +123,36 @@ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): Dee }) } -export function apply(ctx: Context, config: Config): void { +/** + * The one explicit resolve step from raw config to validated connection + * facts. Programmatic construction may bypass Schemastery normalization, so + * every default and bound is re-judged here — for the composition entry at + * load (fail loud) and for each settings snapshot at its first use. + * @param config - raw plugin config or resolved settings snapshot. + * @returns validated connection facts plus the credential reference. + */ +export function resolveAdapterOptions(config: Config): ResolvedDeepSeekOptions { if (config.thinking === 'disabled' && config.reasoningEffort !== undefined && config.reasoningEffort !== 'off') { throw new Error('llm-deepseek: only reasoningEffort "off" can be configured when thinking is disabled') } - const apiKey = config.apiKey ?? process.env.DEEPSEEK_API_KEY - if (apiKey === undefined || apiKey.length === 0) { - throw new Error('llm-deepseek: an API key is required (Config.apiKey or $DEEPSEEK_API_KEY)') + if (config.defaultContextWindow !== undefined + && (!Number.isInteger(config.defaultContextWindow) || config.defaultContextWindow <= 0)) { + throw new Error('llm-deepseek: defaultContextWindow must be a positive integer') } - const baseURL = config.baseURL ?? process.env.DEEPSEEK_BASE_URL ?? PUBLIC_BASE_URL - ctx.llm.registerAdapter(['deepseek'], new DeepSeekAdapter({ - apiKey, - baseURL, + const streamIdleTimeoutMs = config.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS + if (!Number.isFinite(streamIdleTimeoutMs) + || streamIdleTimeoutMs <= 0 + || streamIdleTimeoutMs > MAX_TIMER_DELAY_MS) { + throw new Error( + `llm-deepseek: streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`, + ) + } + 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: { thinking: config.thinking, reasoningEffort: config.reasoningEffort, @@ -120,7 +161,79 @@ export function apply(ctx: Context, config: Config): void { ? {} : { defaultContextWindow: config.defaultContextWindow }, models: resolveModels(config.models), - streamIdleTimeoutMs: config.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS, - ...config.retryPolicy === undefined ? {} : { retryPolicy: config.retryPolicy }, - })) + streamIdleTimeoutMs, + retryPolicy: resolveRetryPolicy(config.retryPolicy, 'llm-deepseek: retryPolicy'), + } +} + +export function apply(ctx: Context, config: Config): void { + let current: () => Config = () => config + let lastRaw: Config | undefined + let lastGood: ResolvedDeepSeekOptions | undefined + const options = (): ResolvedDeepSeekOptions => { + const raw = current() + if (raw === lastRaw && lastGood !== undefined) return lastGood + try { + const next = resolveAdapterOptions(raw) + lastRaw = raw + lastGood = next + return next + } catch (error) { + // Static composition resolves before anything registers, so this branch + // only sees a live settings snapshot failing a beyond-schema bound: + // keep serving the last good facts and say so once per bad snapshot. + if (lastGood === undefined) throw error + lastRaw = raw + ctx.logger.error('llm-deepseek: keeping the last good configuration after an invalid settings section') + ctx.logger.error(error) + return lastGood + } + } + options() + + const resolveApiKey = async (connection: ResolvedDeepSeekOptions): Promise => { + // Every credential fact comes from the caller's snapshot, so a rejected + // settings generation cannot leak its key onto the previous endpoint. + if (connection.apiKey !== undefined) return connection.apiKey + const ref = connection.apiKeyEnv + const credentials = ctx.get('credentials') + if (credentials !== undefined) { + const hit = await credentials.resolve(ref) + if (hit !== undefined) return hit.value + } else { + // Without the seam, keep the historical ambient fallback so a plain + // cordis.yml composition works from the environment alone. + const ambient = process.env[ref] + if (ambient !== undefined && ambient.length > 0) return ambient + } + throw new LlmError( + `llm-deepseek: no API key for provider route "${PROVIDER}"; store ${ref} through the credentials` + + ` service (the web Models page writes it), export ${ref} in the launching environment, or — as a` + + ' last resort — set a literal "apiKey" in the llm-deepseek settings section', + 'MISSING_CREDENTIAL', + ) + } + + const adapter = new DeepSeekAdapter({ options, resolveApiKey }) + // 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([PROVIDER], adapter) + let registeredPolicy = options().retryPolicy + const ensureRegistrationFacts = (): void => { + const policy = options().retryPolicy + if (deepEqualJson(policy, registeredPolicy)) return + // The registry captures the retry policy at registration, so it is the one + // 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([PROVIDER], adapter) + registeredPolicy = policy + } + + installSettingsSection(ctx, NS, Config, config, { + setSource: (source) => { + current = source + }, + onChange: ensureRegistrationFacts, + }) } diff --git a/packages/llm/llm-deepseek/tests/adapter.e2e.ts b/packages/llm/llm-deepseek/tests/adapter.e2e.ts index f6d97031c4..e59af1185d 100644 --- a/packages/llm/llm-deepseek/tests/adapter.e2e.ts +++ b/packages/llm/llm-deepseek/tests/adapter.e2e.ts @@ -1,7 +1,11 @@ -import { afterEach, describe, expect, it } from 'vitest' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import LlmService, { createUserMessage, CallId, ReasoningEffortId , createMessage } from '@deepseek-ai/dsh-llm' import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm' +import { CredentialsLocal } from '@deepseek-ai/dsh-credentials-local' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import type { Config } from '@deepseek-ai/dsh-llm-deepseek' import { assemble, type AssembledResult } from './assemble.ts' @@ -53,6 +57,34 @@ const weatherTool: ToolSchema = { } describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', () => { + it('serves a real request with the key held only by a credentials-local document', async () => { + const key = process.env.DEEPSEEK_API_KEY + if (key === undefined) throw new Error('e2e ran without DEEPSEEK_API_KEY') + const dir = await mkdtemp(join(tmpdir(), 'dsh-e2e-credentials-')) + try { + await writeFile(join(dir, '.env'), `DEEPSEEK_API_KEY=${key}\n`, { mode: 0o600 }) + // Scrub the ambient variable so only the credential seam can supply the + // key: this request proves the per-request resolution path end to end. + vi.stubEnv('DEEPSEEK_API_KEY', '') + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(LlmService) + await ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false }) + await ctx.plugin(LlmDeepSeek, {}) + + const result = await assemble(ctx, { + model: FLASH, + messages: ask('Reply with exactly the word: pong'), + maxTokens: 50, + }) + expect(result.finish.kind).toBe('stop') + expect(textOf(result).toLowerCase()).toContain('pong') + } finally { + vi.unstubAllEnvs() + await rm(dir, { recursive: true, force: true }) + } + }) + it('flash dynamically switches from off to high', async () => { const ctx = await harness(FLASH, { reasoningEffort: 'off' }) const withoutThinking = await assemble(ctx,{ diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 1b64c57982..aec9229e25 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -1,5 +1,3 @@ -import { createServer } from 'node:http' -import type { IncomingMessage, Server, ServerResponse } from 'node:http' import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import LlmService, { createUserMessage, @@ -14,90 +12,18 @@ import LlmService, { createUserMessage, import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { SessionId } from '@deepseek-ai/dsh-session' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' -import { DeepSeekAdapter } from '@deepseek-ai/dsh-llm-deepseek' +import { DeepSeekAdapter, resolveAdapterOptions } from '@deepseek-ai/dsh-llm-deepseek' import { httpErrorCode } from '../src/adapter.ts' import { assemble } from './assemble.ts' - -/** One scripted behavior for the next request the mock server receives. */ -type Behavior = - | { kind: 'sse'; events: string[]; delayMs?: number } - | { kind: 'http-error'; status: number; body: string; contentType?: string; headers?: Record } - | { kind: 'close-early'; events: string[] } - -interface MockServer { - url: string - /** Bodies of received requests, in order. */ - requests: unknown[] - /** Header bags of received requests, in order (parallel to `requests`). */ - headers: IncomingMessage['headers'][] - script: Behavior[] - close(): Promise -} - -const servers: Server[] = [] +import { closeMockServers, mockServer, textEvents } from './mock-server.ts' +import type { Behavior } from './mock-server.ts' afterEach(async () => { - await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve)))) + await closeMockServers() vi.unstubAllEnvs() vi.useRealTimers() }) -/** Local chat-completions stand-in: replays scripted behaviors per request. */ -async function mockServer(script: Behavior[]): Promise { - const requests: unknown[] = [] - const headers: IncomingMessage['headers'][] = [] - const server = createServer((request: IncomingMessage, response: ServerResponse) => { - let body = '' - request.on('data', (chunk: Buffer) => { body += chunk.toString('utf8') }) - request.on('end', () => { - requests.push(JSON.parse(body)) - headers.push(request.headers) - const behavior = script.shift() - if (!behavior) { - response.writeHead(500).end('mock script exhausted') - return - } - if (behavior.kind === 'http-error') { - response.writeHead(behavior.status, { - 'content-type': behavior.contentType ?? 'application/json', - ...behavior.headers, - }) - response.end(behavior.body) - return - } - response.writeHead(200, { 'content-type': 'text/event-stream' }) - const write = (index: number): void => { - if (index >= behavior.events.length) { - if (behavior.kind === 'sse') response.end() - else response.destroy() // close-early: drop the socket mid-stream - return - } - response.write(`data: ${behavior.events[index]}\n\n`) - setTimeout(() => { write(index + 1) }, behavior.kind === 'sse' ? behavior.delayMs ?? 0 : 5) - } - write(0) - }) - }) - servers.push(server) - await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) - const address = server.address() - if (address === null || typeof address === 'string') throw new Error('no port') - return { - url: `http://127.0.0.1:${address.port}`, - requests, - headers, - script, - close: () => new Promise(resolve => server.close(() => { resolve() })), - } -} - -const textEvents = [ - '{"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}', - '{"choices":[{"delta":{"content":"hello"}}]}', - '{"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}', - '[DONE]', -] - async function harness(baseURL: string, config: object = {}) { const ctx = new Context() await ctx.plugin(LlmService) @@ -105,6 +31,15 @@ async function harness(baseURL: string, config: object = {}) { return ctx } +/** Direct adapter over the plugin's real resolve step, with a static key. */ +function adapterOf(config: Partial & { apiKey?: string } = {}): DeepSeekAdapter { + const { apiKey, ...rest } = config + return new DeepSeekAdapter({ + options: () => resolveAdapterOptions(rest), + resolveApiKey: () => Promise.resolve(apiKey ?? 'k'), + }) +} + describe('DeepSeekAdapter against a mock server', () => { it('streams a text generation end to end through the assembler', async () => { const server = await mockServer([{ kind: 'sse', events: textEvents }]) @@ -275,11 +210,7 @@ describe('DeepSeekAdapter against a mock server', () => { 'rejects direct adapter effort %s before I/O when thinking is disabled', async (effort) => { const server = await mockServer([]) - const adapter = new DeepSeekAdapter({ - apiKey: 'test-key', - baseURL: server.url, - defaults: { thinking: 'disabled' }, - }) + const adapter = adapterOf({ apiKey: 'test-key', baseURL: server.url, thinking: 'disabled' }) const stream = adapter.stream({ provider: 'deepseek', @@ -483,7 +414,7 @@ describe('DeepSeekAdapter against a mock server', () => { }) it('throws EMPTY_RESPONSE when the response has no body', async () => { - const adapter = new DeepSeekAdapter({ apiKey: 'k', baseURL: 'http://127.0.0.1:1' }) + const adapter = adapterOf({ baseURL: 'http://127.0.0.1:1' }) const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( new Response(null, { status: 200 }), ) @@ -538,7 +469,7 @@ describe('DeepSeekAdapter against a mock server', () => { it('maps connection failures to TRANSPORT without losing the cause', async () => { const cause = new TypeError('connection refused') const fetchSpy = vi.spyOn(globalThis, 'fetch').mockRejectedValue(cause) - const adapter = new DeepSeekAdapter({ apiKey: 'k', baseURL: 'https://example.invalid' }) + const adapter = adapterOf({ baseURL: 'https://example.invalid' }) try { const drain = async (): Promise => { for await (const _chunk of adapter.stream({ provider: 'deepseek', model: 'm', messages: [] })) { /* drain */ } @@ -555,7 +486,7 @@ describe('DeepSeekAdapter against a mock server', () => { failed.reject('offline') return failed.promise }) - const adapter = new DeepSeekAdapter({ apiKey: 'k', baseURL: 'https://example.invalid' }) + const adapter = adapterOf({ baseURL: 'https://example.invalid' }) try { const drain = async (): Promise => { for await (const _chunk of adapter.stream({ provider: 'deepseek', model: 'm', messages: [] })) { /* drain */ } @@ -585,11 +516,7 @@ describe('DeepSeekAdapter against a mock server', () => { }) return Promise.resolve(new Response(body, { status: 200 })) }) - const adapter = new DeepSeekAdapter({ - apiKey: 'k', - baseURL: 'https://example.invalid', - streamIdleTimeoutMs: 100, - }) + const adapter = adapterOf({ baseURL: 'https://example.invalid', streamIdleTimeoutMs: 100 }) try { const drain = (async () => { for await (const _chunk of adapter.stream({ provider: 'deepseek', model: 'm', messages: [] })) { /* drain */ } @@ -733,22 +660,15 @@ describe('plugin registration and config', () => { ) it.each(['high', 'max'] as const)( - 'rejects disabled-thinking effort %s at the direct constructor boundary', + 'rejects disabled-thinking effort %s at the resolver boundary', (reasoningEffort) => { - expect(() => new DeepSeekAdapter({ - apiKey: 'k', - baseURL: 'http://127.0.0.1:1', - defaults: { thinking: 'disabled', reasoningEffort }, - })).toThrow(/only reasoningEffort "off"/) + expect(() => resolveAdapterOptions({ thinking: 'disabled', reasoningEffort })) + .toThrow(/only reasoningEffort "off"/) }, ) - it('accepts disabled thinking with off at the direct constructor boundary', async () => { - const adapter = new DeepSeekAdapter({ - apiKey: 'k', - baseURL: 'http://127.0.0.1:1', - defaults: { thinking: 'disabled', reasoningEffort: 'off' }, - }) + it('accepts disabled thinking with off at the resolver boundary', async () => { + const adapter = adapterOf({ thinking: 'disabled', reasoningEffort: 'off' }) await expect(adapter.resolveModel('deepseek', 'pass-through')).resolves.toMatchObject({ reasoning: { efforts: [{ id: ReasoningEffortId('off'), name: 'Off' }], @@ -863,11 +783,8 @@ describe('plugin registration and config', () => { it.each([0, 1.5])( 'rejects invalid adapter-wide default context capacity %s', async (defaultContextWindow) => { - expect(() => new DeepSeekAdapter({ - apiKey: 'k', - baseURL: 'http://127.0.0.1:1', - defaultContextWindow, - })).toThrow(/defaultContextWindow must be a positive integer/) + expect(() => resolveAdapterOptions({ defaultContextWindow })) + .toThrow(/defaultContextWindow must be a positive integer/) const ctx = new Context() await ctx.plugin(LlmService) @@ -889,13 +806,42 @@ describe('plugin registration and config', () => { expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }]) }) - it('throws a clear error when no API key is available', async () => { + it('loads keyless, keeps the catalog browsable, and fails the request actionably', async () => { vi.stubEnv('DEEPSEEK_API_KEY', '') const ctx = new Context() await ctx.plugin(LlmService) - await expect(ctx.plugin(LlmDeepSeek, {})) - .rejects.toThrow(/an API key is required/) - expect(ctx.llm.listProviders()).toEqual([]) + await ctx.plugin(LlmDeepSeek, { baseURL: 'http://127.0.0.1:1' }) + // First-boot onboarding: the route registers so models stay discoverable; + // only the request itself needs a key. + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }]) + await expect(ctx.llm.listModels('deepseek')).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 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 () => { @@ -927,23 +873,32 @@ describe('plugin registration and config', () => { expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }]) }) - it('adapter is constructible directly for embedding', async () => { - const adapter = new DeepSeekAdapter({ apiKey: 'k', baseURL: 'http://127.0.0.1:1' }) + it('adapter is constructible directly for embedding over the shared resolver', async () => { + const adapter = adapterOf() expect(adapter).toBeInstanceOf(DeepSeekAdapter) - await expect(adapter.listModels('deepseek')).resolves.toEqual([]) + // Direct embedding shares the plugin's one resolve step, so it advertises + // the same default catalog instead of a divergent empty one. + await expect(adapter.listModels('deepseek')).resolves.toHaveLength(2) + }) + + it('resolves connection facts and the credential exactly once per stream call', async () => { + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const options = vi.fn(() => resolveAdapterOptions({ baseURL: server.url })) + const resolveApiKey = vi.fn(() => Promise.resolve('per-request-key')) + const adapter = new DeepSeekAdapter({ options, resolveApiKey }) + + for await (const _chunk of adapter.stream({ provider: 'deepseek', model: 'm', messages: [] })) { /* drain */ } + + expect(options).toHaveBeenCalledTimes(1) + expect(resolveApiKey).toHaveBeenCalledTimes(1) + expect(server.headers[0]?.authorization).toBe('Bearer per-request-key') }) it('rejects invalid idle watchdog bounds for direct and plugin composition', async () => { - expect(() => new DeepSeekAdapter({ - apiKey: 'k', - baseURL: 'http://127.0.0.1:1', - streamIdleTimeoutMs: Number.POSITIVE_INFINITY, - })).toThrow(/streamIdleTimeoutMs.*positive finite/) - expect(() => new DeepSeekAdapter({ - apiKey: 'k', - baseURL: 'http://127.0.0.1:1', - streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1, - })).toThrow(/streamIdleTimeoutMs.*no greater/) + expect(() => resolveAdapterOptions({ streamIdleTimeoutMs: Number.POSITIVE_INFINITY })) + .toThrow(/streamIdleTimeoutMs.*positive finite/) + expect(() => resolveAdapterOptions({ streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1 })) + .toThrow(/streamIdleTimeoutMs.*no greater/) const ctx = new Context() await ctx.plugin(LlmService) diff --git a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts new file mode 100644 index 0000000000..3cd430ec14 --- /dev/null +++ b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts @@ -0,0 +1,186 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +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 { credentialRef } from '@deepseek-ai/dsh-credentials' +import { CredentialsLocal } from '@deepseek-ai/dsh-credentials-local' +import { settingsNamespace } from '@deepseek-ai/dsh-settings' +import { SettingsLocal } from '@deepseek-ai/dsh-settings-local' +import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' +import { assemble } from './assemble.ts' +import { closeMockServers, mockServer, textEvents } from './mock-server.ts' + +const NS = settingsNamespace('llm-deepseek') +const KEY_REF = credentialRef('DEEPSEEK_API_KEY') + +const cleanups: Array<() => Promise> = [] + +afterEach(async () => { + while (cleanups.length > 0) await cleanups.pop()!() + await closeMockServers() + vi.unstubAllEnvs() +}) + +async function home(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'dsh-llm-dynamic-')) + cleanups.push(() => rm(dir, { recursive: true, force: true })) + return dir +} + +interface Harness { + ctx: Context + settingsFiber: { dispose(): Promise } +} + +/** + * Real dynamic composition: llm + settings-local + credentials-local + + * llm-deepseek over one temp harness home. `watch: false` keeps every change + * flowing through the in-process write path, which is deterministic; external + * file watching is the providers' own covered concern. + */ +async function boot(dir: string, config: object): Promise { + const ctx = new Context() + cleanups.push(async () => { + await ctx.fiber.dispose() + }) + await ctx.plugin(LlmService) + const settingsFiber = ctx.plugin(SettingsLocal, { path: join(dir, 'settings.yaml'), watch: false }) + await settingsFiber + await ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false }) + await ctx.plugin(LlmDeepSeek, config) + return { ctx, settingsFiber } +} + +function prompt(ctx: Context) { + return assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) +} + +describe('request-level dynamic configuration', () => { + it('routes the next request with the freshly resolved base URL and credential', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', '') + const dir = await home() + await writeFile(join(dir, '.env'), 'DEEPSEEK_API_KEY=first-key\n') + const serverA = await mockServer([{ kind: 'sse', events: textEvents }]) + const serverB = await mockServer([{ kind: 'sse', events: textEvents }]) + const { ctx } = await boot(dir, { baseURL: serverA.url }) + + await prompt(ctx) + expect(serverA.headers[0]?.authorization).toBe('Bearer first-key') + + await ctx.settings.update(NS, { baseURL: serverB.url }) + await ctx.credentials.set(KEY_REF, 'second-key') + + await prompt(ctx) + // No restart, no re-registration: the next request resolved both facts. + expect(serverA.requests).toHaveLength(1) + expect(serverB.headers[0]?.authorization).toBe('Bearer second-key') + }) + + it('prefers a literal settings apiKey over the credential layers', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', '') + const dir = await home() + await writeFile(join(dir, '.env'), 'DEEPSEEK_API_KEY=file-key\n') + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const { ctx } = await boot(dir, { baseURL: server.url }) + + await ctx.settings.update(NS, { apiKey: 'literal-key' }) + await prompt(ctx) + expect(server.headers[0]?.authorization).toBe('Bearer literal-key') + }) + + it('starts keyless and serves the next request once the key arrives', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', '') + const dir = await home() + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const { ctx } = await boot(dir, { baseURL: server.url }) + + await expect(prompt(ctx)).rejects.toMatchObject({ code: 'MISSING_CREDENTIAL' }) + await ctx.credentials.set(KEY_REF, 'sk-arrived') + await prompt(ctx) + expect(server.headers[0]?.authorization).toBe('Bearer sk-arrived') + }) + + it('advertises a live settings catalog without re-registration', async () => { + const dir = await home() + const { ctx } = await boot(dir, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' }) + + await expect(ctx.llm.listModels('deepseek')).resolves.toHaveLength(2) + await ctx.settings.update(NS, { models: [{ id: 'settings-model', name: 'From Settings' }] }) + await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([ + { provider: 'deepseek', id: 'settings-model', name: 'From Settings' }, + ]) + }) + + it('re-registers the route in place when the captured retry policy changes', async () => { + const dir = await home() + const { ctx } = await boot(dir, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' }) + + await ctx.settings.update(NS, { + retryPolicy: { mode: 'always', backoff: { initialDelayMs: 25, maxDelayMs: 100, jitterRatio: 0.2 } }, + }) + expect(ctx.llm.providerRetryPolicy('deepseek')).toEqual({ + mode: 'always', + initialDelayMs: 25, + maxDelayMs: 100, + jitterRatio: 0.2, + }) + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }]) + }) + + it('keeps the last good options when a settings snapshot fails beyond-schema validation', async () => { + const dir = await home() + const { ctx } = await boot(dir, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' }) + + // Schema-valid but resolver-invalid: duplicate catalog ids pass the array + // schema and fail the explicit resolve step. + await ctx.settings.update(NS, { models: [{ id: 'dup' }, { id: 'dup' }] }) + await expect(ctx.llm.listModels('deepseek')).resolves.toHaveLength(2) + await ctx.settings.update(NS, { models: [{ id: 'recovered' }] }) + await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([ + { provider: 'deepseek', id: 'recovered', name: 'recovered' }, + ]) + }) + + 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() + await writeFile(join(dir, '.env'), 'DEEPSEEK_API_KEY=steady-key\n') + const serverA = await mockServer([{ kind: 'sse', events: textEvents }]) + const serverB = await mockServer([{ kind: 'sse', events: textEvents }]) + const { ctx, settingsFiber } = await boot(dir, { baseURL: serverA.url }) + + await ctx.settings.update(NS, { baseURL: serverB.url }) + await prompt(ctx) + expect(serverB.requests).toHaveLength(1) + + await settingsFiber.dispose() + await prompt(ctx) + expect(serverA.requests).toHaveLength(1) + expect(serverA.headers[0]?.authorization).toBe('Bearer steady-key') + }) +}) diff --git a/packages/llm/llm-deepseek/tests/loader-composition.spec.ts b/packages/llm/llm-deepseek/tests/loader-composition.spec.ts new file mode 100644 index 0000000000..402f94441d --- /dev/null +++ b/packages/llm/llm-deepseek/tests/loader-composition.spec.ts @@ -0,0 +1,174 @@ +/** + * Real-composition guard for the dynamic-configuration chain: LlmService, + * settings-local, credentials-local, and llm-deepseek boot from a test-only + * cordis.yml through the actual Loader + Include path, external edits of + * settings.yaml and .env hot-publish through their providers, and the very + * next request carries the fresh base URL and credential. The same adapter + * composition without settings or credentials entries keeps entry-config + * behavior — the documented optional-inject fallback. + */ + +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 { credentialRef } from '@deepseek-ai/dsh-credentials' +import CredentialsLocal from '@deepseek-ai/dsh-credentials-local' +import { settingsNamespace } from '@deepseek-ai/dsh-settings' +import SettingsLocal from '@deepseek-ai/dsh-settings-local' +import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' +import { assemble } from './assemble.ts' +import { closeMockServers, mockServer, textEvents } from './mock-server.ts' + +const NS = settingsNamespace('llm-deepseek') +const KEY_REF = credentialRef('DEEPSEEK_API_KEY') + +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() +}) + +async function loadComposition( + options: { withDynamic: boolean; baseURL: string; reuseRoot?: string }, +): Promise<{ ctx: Context; settingsPath: string; envPath: string }> { + // 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 && fresh) { + await writeFile(settingsPath, '# personal settings\n') + await writeFile(envPath, 'DEEPSEEK_API_KEY=boot-key\n') + } + + const configPath = join(root, 'cordis.yml') + await writeFile(configPath, [ + '- id: llm', + " name: 'test-llm-service'", + ...options.withDynamic + ? [ + '- 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(envPath)}`, + ' debounceMs: 10', + ] + : [], + '- id: llm-deepseek', + " name: '@deepseek-ai/dsh-llm-deepseek'", + ' config:', + ` baseURL: ${JSON.stringify(options.baseURL)}`, + ...options.withDynamic ? [] : [' apiKey: entry-key'], + '', + ].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([ + ['test-llm-service', LlmService], + ['@deepseek-ai/dsh-settings-local', SettingsLocal], + ['@deepseek-ai/dsh-credentials-local', CredentialsLocal], + ['@deepseek-ai/dsh-llm-deepseek', LlmDeepSeek], + ]) + 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 + await ctx.loader.create({ + name: 'cordis:include', + config: { path: pathToFileURL(configPath).href }, + }) + await ctx.loader.await() + return { ctx, settingsPath, envPath } +} + +describe('llm-deepseek real dynamic composition', () => { + it('boots from cordis.yml and routes the next request after external settings and .env edits', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', '') + const serverA = await mockServer([{ kind: 'sse', events: textEvents }]) + const serverB = await mockServer([{ kind: 'sse', events: textEvents }]) + const { ctx, settingsPath, envPath } = await loadComposition({ withDynamic: true, baseURL: serverA.url }) + + expect(ctx.get('settings')!.describe().map(entry => entry.ns)).toEqual([NS]) + await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + expect(serverA.headers[0]?.authorization).toBe('Bearer boot-key') + + // External edits, exactly as a user or the web UI would leave them on disk. + await writeFile(settingsPath, `llm-deepseek:\n baseURL: ${serverB.url}\n`) + await vi.waitFor(() => { + expect((ctx.get('settings')!.get(NS) as { baseURL?: string }).baseURL).toBe(serverB.url) + }, { timeout: 5000 }) + await writeFile(envPath, 'DEEPSEEK_API_KEY=rotated-key\n') + await vi.waitFor(async () => { + expect(await ctx.get('credentials')!.resolve(KEY_REF)).toEqual({ value: 'rotated-key', source: 'file' }) + }, { timeout: 5000 }) + + await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + expect(serverA.requests).toHaveLength(1) + 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 }]) + const { ctx } = await loadComposition({ withDynamic: false, baseURL: server.url }) + + expect(ctx.get('settings')).toBeUndefined() + expect(ctx.get('credentials')).toBeUndefined() + await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + expect(server.headers[0]?.authorization).toBe('Bearer entry-key') + }) +}) diff --git a/packages/llm/llm-deepseek/tests/mock-server.ts b/packages/llm/llm-deepseek/tests/mock-server.ts new file mode 100644 index 0000000000..cdb499e143 --- /dev/null +++ b/packages/llm/llm-deepseek/tests/mock-server.ts @@ -0,0 +1,82 @@ +import { createServer } from 'node:http' +import type { IncomingMessage, Server, ServerResponse } from 'node:http' + +/** One scripted behavior for the next request the mock server receives. */ +export type Behavior = + | { kind: 'sse'; events: string[]; delayMs?: number } + | { kind: 'http-error'; status: number; body: string; contentType?: string; headers?: Record } + | { kind: 'close-early'; events: string[] } + +export interface MockServer { + url: string + /** Bodies of received requests, in order. */ + requests: unknown[] + /** Header bags of received requests, in order (parallel to `requests`). */ + headers: IncomingMessage['headers'][] + script: Behavior[] + close(): Promise +} + +const servers: Server[] = [] + +/** Close every server opened since the last call; run from each spec's afterEach. */ +export async function closeMockServers(): Promise { + await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve)))) +} + +/** A minimal complete text generation, reused by request-shape assertions. */ +export const textEvents = [ + '{"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}', + '{"choices":[{"delta":{"content":"hello"}}]}', + '{"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}', + '[DONE]', +] + +/** Local chat-completions stand-in: replays scripted behaviors per request. */ +export async function mockServer(script: Behavior[]): Promise { + const requests: unknown[] = [] + const headers: IncomingMessage['headers'][] = [] + const server = createServer((request: IncomingMessage, response: ServerResponse) => { + let body = '' + request.on('data', (chunk: Buffer) => { body += chunk.toString('utf8') }) + request.on('end', () => { + requests.push(JSON.parse(body)) + headers.push(request.headers) + const behavior = script.shift() + if (!behavior) { + response.writeHead(500).end('mock script exhausted') + return + } + if (behavior.kind === 'http-error') { + response.writeHead(behavior.status, { + 'content-type': behavior.contentType ?? 'application/json', + ...behavior.headers, + }) + response.end(behavior.body) + return + } + response.writeHead(200, { 'content-type': 'text/event-stream' }) + const write = (index: number): void => { + if (index >= behavior.events.length) { + if (behavior.kind === 'sse') response.end() + else response.destroy() // close-early: drop the socket mid-stream + return + } + response.write(`data: ${behavior.events[index]}\n\n`) + setTimeout(() => { write(index + 1) }, behavior.kind === 'sse' ? behavior.delayMs ?? 0 : 5) + } + write(0) + }) + }) + servers.push(server) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('no port') + return { + url: `http://127.0.0.1:${address.port}`, + requests, + headers, + script, + close: () => new Promise(resolve => server.close(() => { resolve() })), + } +} diff --git a/packages/llm/llm-deepseek/tsconfig.json b/packages/llm/llm-deepseek/tsconfig.json index 45c2af21a5..ee8a81e73b 100644 --- a/packages/llm/llm-deepseek/tsconfig.json +++ b/packages/llm/llm-deepseek/tsconfig.json @@ -20,6 +20,12 @@ { "path": "../../llm/llm" }, + { + "path": "../../credentials/credentials" + }, + { + "path": "../../settings/settings" + }, { "path": "../../support/invariants" }, diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index fb47822ca5..25e825eead 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/README.i18n.yaml @@ -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: ac47cf6a21285fc887948a5a7798a9f1cb9157b0 -README.zh.md: 650ec7a578549cec6bd001e15ff5be4dea86e942 +README.md: 0099c9acd39cd2d471936505726d68423f351c76 +README.zh.md: 7cb4f5fcbc1c7a67b77d690031cc7d553569433f diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index ac47cf6a21..0099c9acd3 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -2,21 +2,21 @@ English | [中文](README.zh.md) -Generic multi-provider adapter for the harness LLM seam backed by [`@earendil-works/pi-ai`](https://www.npmjs.com/package/@earendil-works/pi-ai). One plugin instance owns an explicit list of provider profiles; every request selects a profile with `GenerateOptions.provider` and resolves `GenerateOptions.model` dynamically from pi-ai's installed catalog. +Generic multi-provider adapter for the harness LLM seam backed by [`@earendil-works/pi-ai`](https://www.npmjs.com/package/@earendil-works/pi-ai). One plugin instance owns a dict of provider profiles keyed by route; every request selects a profile with `GenerateOptions.provider` and resolves `GenerateOptions.model` dynamically from pi-ai's installed catalog. The package root exposes the Cordis plugin contract and `PiAiAdapter`; profile resolution, model construction, replay conversion, and stream conversion remain package-internal. ## Config -Configure credentials and deployment-specific transport settings per provider. Omitting `apiKey` 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 name: '@deepseek-ai/dsh-llm-pi-ai' config: providers: - - provider: openai - apiKey: !!js process.env.OPENAI_API_KEY + openai: + apiKeyEnv: OPENAI_API_KEY baseURL: https://proxy.example.com:8443 reasoning: high retryPolicy: @@ -26,22 +26,28 @@ Configure credentials and deployment-specific transport settings per provider. O initialDelayMs: 500 maxDelayMs: 10000 jitterRatio: 0.1 - - provider: anthropic - apiKey: !!js process.env.ANTHROPIC_API_KEY + anthropic: + apiKeyEnv: ANTHROPIC_API_KEY streamIdleTimeoutMs: 300000 - - provider: openrouter - apiKey: !!js process.env.OPENROUTER_API_KEY + openrouter: + apiKeyEnv: OPENROUTER_API_KEY headers: X-Deployment: production ``` -Each provider name must exist in pi-ai's installed catalog and may appear only once in this plugin instance. Registration with `ctx.llm` is atomic: a collision with any provider route already owned by another adapter fails plugin loading without registering the remaining routes. Model ids are not lifecycle config; an unknown model fails before any provider request with `LlmError('UNKNOWN_MODEL')`. +Each dict key must exist in pi-ai's installed catalog; the dict shape makes duplicates unrepresentable, and the pre-release array shape (with per-profile `provider` fields) fails load with migration directions. `providers` may also be empty or omitted entirely: the adapter then mounts **dormant** — zero routes, no extra catalog entries — and registers routes the moment the `llm-pi-ai:` settings section supplies profiles, dropping them again when it empties. Which adapters exist is composition; which providers run can be entirely the user's settings document. Registration with `ctx.llm` is atomic: a collision with any provider route already owned by another adapter fails plugin loading without registering the remaining routes. Model ids are not lifecycle config; an unknown model fails before any provider request with `LlmError('UNKNOWN_MODEL')`. + +## Dynamic configuration (settings + credentials) + +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; 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. The `reasoning.efforts` list is pi-ai's ordered `getSupportedThinkingLevels(model)` result without filtering or normalization, including `off` and the model-specific availability of `xhigh` or `max`. The Harness exposes each canonical pi-ai level as an opaque ID; provider/model wire spellings remain inside pi-ai's `thinkingLevelMap`. A non-reasoning model therefore exposes pi-ai's `off` choice. The profile `reasoning` value, including `off`, is the deployment default when configured; omitting it preserves the provider default. Per-request `GenerateOptions.reasoningEffort` takes precedence, and any explicit value absent from the exact model capability fails with `UNSUPPORTED_REASONING_EFFORT` before network I/O instead of being clamped. pi-ai's common stream options represent `off` by omitting `reasoning`. -Supported profile fields are `provider`, `apiKey`, `baseURL`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, and `retryPolicy`. Each profile's optional retry policy is captured with that provider route; omission uses bounded normal defaults. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Harness app attribution wins a conflicting configured header name. +Supported profile fields are `apiKey`, `apiKeyEnv`, `baseURL`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, and `retryPolicy`. Each profile's optional retry policy is captured with that provider route; omission uses bounded normal defaults. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Harness app attribution wins a conflicting configured header name. The adapter forces pi-ai's SDK `maxRetries` to zero so one `stream()` call makes one provider request. The removed profile fields `maxRetries` and `maxRetryDelayMs` fail load instead of silently multiplying or hiding the separately composed agent-level retry budget. Idle expiry aborts the SDK's stable request signal and surfaces `TIMEOUT`; an earlier caller abort remains `ABORTED`. @@ -71,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. 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 @@ -105,6 +111,8 @@ Recorded response content appends to the next request and does not invalidate it ## Known Limitations and Deferred Work +- **Settings can add or override routes, not remove composition routes** — the user layer merges over the composition `base`, so deleting a `cordis.yml`-provided provider is a composition change; `replace` on the namespace only resets the user layer. +- **`apiKey` is schema-tagged `role('secret')` but not yet masked anywhere** — the settings `describe()` envelope returns values verbatim; the wire/UI layer that must redact secret-role fields ships with the settings RPC surface. - **Catalog membership is required** — custom model ids that are absent from the installed pi-ai catalog fail with `UNKNOWN_MODEL`, even when a provider profile supplies a custom endpoint. - **`GenerateOptions.stop` is unsupported** — pi-ai's common stream options cannot guarantee stop-sequence behavior across providers, so the adapter rejects the field. - **In-history `system` messages use pi-ai's common context conversion** — provider-specific placement follows pi-ai rather than a harness-owned wire override. diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index 650ec7a578..7cb4f5fcbc 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -2,21 +2,21 @@ [English](README.md) | 中文 -基于 [`@earendil-works/pi-ai`](https://www.npmjs.com/package/@earendil-works/pi-ai) 的 harness LLM(大语言模型)seam 通用多提供方适配器。一个插件实例拥有显式提供方 profile 列表;每个请求使用 `GenerateOptions.provider` 选择 profile,并从 pi-ai 已安装 catalog 中动态解析 `GenerateOptions.model`。 +基于 [`@earendil-works/pi-ai`](https://www.npmjs.com/package/@earendil-works/pi-ai) 的 harness LLM(大语言模型)seam 通用多提供方适配器。一个插件实例拥有一份以路由为键的提供方 profile 字典;每个请求使用 `GenerateOptions.provider` 选择 profile,并从 pi-ai 已安装 catalog 中动态解析 `GenerateOptions.model`。 包(package)根入口导出 Cordis 插件契约与 `PiAiAdapter`;profile 解析、模型构造、回放转换和流转换保留在包内部。 ## 配置 -按提供方配置凭证与部署特定传输设置。省略 `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 name: '@deepseek-ai/dsh-llm-pi-ai' config: providers: - - provider: openai - apiKey: !!js process.env.OPENAI_API_KEY + openai: + apiKeyEnv: OPENAI_API_KEY baseURL: https://proxy.example.com:8443 reasoning: high retryPolicy: @@ -26,22 +26,28 @@ initialDelayMs: 500 maxDelayMs: 10000 jitterRatio: 0.1 - - provider: anthropic - apiKey: !!js process.env.ANTHROPIC_API_KEY + anthropic: + apiKeyEnv: ANTHROPIC_API_KEY streamIdleTimeoutMs: 300000 - - provider: openrouter - apiKey: !!js process.env.OPENROUTER_API_KEY + openrouter: + apiKeyEnv: OPENROUTER_API_KEY headers: X-Deployment: production ``` -每个提供方名称必须存在于 pi-ai 已安装 catalog 中,且在此插件实例中最多出现一次。向 `ctx.llm` 注册具有原子性:如果与另一适配器已拥有的任何提供方路由冲突,插件会加载失败,不注册剩余路由。模型 id 不是生命周期配置;未知模型会在发起任何提供方请求前以 `LlmError('UNKNOWN_MODEL')` 失败。 +每个字典键都必须存在于 pi-ai 已安装 catalog 中;字典形状使重复项无法表示,发布前的数组形状(每个 profile 携带 `provider` 字段)会加载失败并给出迁移指引。`providers` 也可以为空或整体省略:适配器将以**休眠**姿态挂载——零路由、模型选择器不多一条——一旦 `llm-pi-ai:` settings 分节提供了 profile 就即时注册路由,分节清空时随之撤销。哪些适配器存在归组合面;哪些提供方在运行可以完全交给用户的设置文档。向 `ctx.llm` 注册具有原子性:如果与另一适配器已拥有的任何提供方路由冲突,插件会加载失败,不注册剩余路由。模型 id 不是生命周期配置;未知模型会在发起任何提供方请求前以 `LlmError('UNKNOWN_MODEL')` 失败。 + +## 动态配置(settings + credentials) + +适配器经由一个 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 时恰好读取该环境变量)。只有完全没有点名任何凭据的 profile——仅限这一种情况——才交给 pi-ai 的环境发现。路由集合与每条路由捕获的重试策略是注册级事实:两者任一变化时,插件都会原子地替换自己的注册(同一适配器实例,候选集合先经校验),因此某条路由若已被另一适配器占有,先前的路由会继续服务,而改回可用配置时注册会重新生效。提供方键的顺序绝不算作变化。存活 settings 快照若点名未知提供方(或违反任何其他 resolver 约束),则保留最后可用 profile 并记录失败;entry 配置本身仍会使插件加载失败。 适配器通过 `ctx.llm.listModels(provider)` 公开每个已配置提供方已安装的 pi-ai 模型。这是从 `getModels(provider)` 派生的提供方无关 selector 元数据;请求时解析仍会执行权威 catalog 查找,因此发现不会创建第二个模型注册表。`ctx.llm.resolveModelInfo(provider, model)` 会执行一次精确 descriptor 查找,并返回其身份、上下文窗口和可选思考级别,让权威元数据保留在拥有路由的适配器上,而非消费方。 `reasoning.efforts` 列表是 pi-ai 有序的 `getSupportedThinkingLevels(model)` 结果,不经筛选或规范化,其中包括 `off`,以及模型对 `xhigh` 或 `max` 的特定支持。Harness 将每个规范 pi-ai 级别公开为不透明 ID;提供方/模型在协议格式中的表示仍保留在 pi-ai 的 `thinkingLevelMap` 中。因此,不具备推理(reasoning)能力的模型也会公开 pi-ai 的 `off` 选项。配置 profile 的 `reasoning` 值(包括 `off`)在存在时是部署默认值;省略它会保留提供方默认值。每次请求的 `GenerateOptions.reasoningEffort` 优先;任何未出现在确切模型能力中的显式值都会在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败,而不会被自动调整。pi-ai 的通用流选项通过省略 `reasoning` 表示 `off`。 -受支持的 profile 字段是 `provider`、`apiKey`、`baseURL`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs` 和 `retryPolicy`。每个 profile 的可选重试策略都会与该提供方路由一同捕获;省略时使用有界的常规默认值。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。若已配置标头中有同名项,则以 Harness 应用归因为准。 +受支持的 profile 字段是 `apiKey`、`apiKeyEnv`、`baseURL`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs` 和 `retryPolicy`。每个 profile 的可选重试策略都会与该提供方路由一同捕获;省略时使用有界的常规默认值。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。若已配置标头中有同名项,则以 Harness 应用归因为准。 适配器强制 pi-ai SDK `maxRetries` 为零,因此一次 `stream()` 调用只会发起一次提供方请求。已移除 profile 字段 `maxRetries` 和 `maxRetryDelayMs` 会使加载失败,而不是静默倍增或隐藏单独组合的 agent(智能体)级重试预算。空闲超时会 abort SDK 的稳定请求信号,并以 `TIMEOUT` 呈现;较早的调用方 abort 仍为 `ABORTED`。 @@ -71,7 +77,7 @@ pi-ai 会安装多个提供方 SDK,并延迟加载 catalog 模型所选的 SDK ## 测试 -单元测试使用重定向到本地 mock 服务器的 pi-ai catalog 模型,覆盖提供方/profile 路由、每次适配器调用只发起一个协议请求、idle-timeout 响应终止、调用方 abort、原生 API 选择、端点覆盖、归因、转换、回放状态验证,以及一个适配器实例内的跨提供方/模型回放。真实 API 覆盖仍需 key 才会启用,并通过 `pnpm run test:e2e` 运行。 +单元测试使用重定向到本地 mock 服务器的 pi-ai catalog 模型,覆盖提供方/profile 路由、每次适配器调用只发起一个协议请求、idle-timeout 响应终止、调用方 abort、原生 API 选择、端点覆盖、归因、转换、回放状态验证,以及一个适配器实例内的跨提供方/模型回放。`tests/dynamic-config.spec.ts` 驱动真实的 settings-local 与 credentials-local provider:settings 里新生的路由实时完成注册,并在用户层重置时随之移除,`apiKeyEnv` 凭据在两次请求之间轮换,点名未知提供方的快照则保留最后可用 profile。`tests/loader-composition.spec.ts` 从仅测试用的 `cordis.yml` 出发,经真实 Loader 拉起休眠姿态,并从磁盘上的一次 `settings.yaml` 编辑注册出它的路由。真实 API 覆盖仍需 key 才会启用,并通过 `pnpm run test:e2e` 运行。 ## 模型体验 @@ -105,6 +111,8 @@ pi-ai 事件会变为 harness 推理、文本、工具调用、usage 与 finish ## 已知限制与暂缓事项 +- **settings 能新增或覆盖路由,但不能移除组合路由**:用户层合并在组合 `base` 之上,因此删除 `cordis.yml` 提供的提供方属于组合变更;对该 namespace 执行 `replace` 只会重置用户层。 +- **`apiKey` 已在 schema 中标注 `role('secret')`,但尚未在任何地方脱敏**:settings 的 `describe()` 信封原样返回值;负责对 secret 角色字段脱敏的 wire/UI 层将随 settings RPC 面一起交付。 - **必须属于 catalog**:已安装 pi-ai catalog 中不存在的自定义模型 id 会以 `UNKNOWN_MODEL` 失败,即使提供方 profile 配置了自定义端点。 - **不支持 `GenerateOptions.stop`**:pi-ai 的通用流选项无法保证所有提供方都支持 stop sequence,因此适配器会拒绝该字段。 - **历史中的 `system` 消息使用 pi-ai 通用上下文转换**:提供方特定位置由 pi-ai 决定,而非由 harness 拥有的协议覆盖决定。 diff --git a/packages/llm/llm-pi-ai/package.json b/packages/llm/llm-pi-ai/package.json index e2b639624b..43b97a14f0 100644 --- a/packages/llm/llm-pi-ai/package.json +++ b/packages/llm/llm-pi-ai/package.json @@ -27,8 +27,10 @@ ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-credentials": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-settings": "^0.0.1", "@deepseek-ai/dsh-timeout": "^0.0.1", "cordis": "^4.0.0-rc.7" }, @@ -37,9 +39,11 @@ "schemastery": "^3.18.0" }, "devDependencies": { + "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index 0cc6dda739..030592f74c 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -30,15 +30,22 @@ import type { StreamChunk, } from '@deepseek-ai/dsh-llm' import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout' -import { resolveProfiles } from './config.ts' -import type { PiAiProviderProfile, ResolvedPiAiProviderProfile } from './config.ts' +import type { ResolvedPiAiProviderProfile } from './config.ts' import { toPiContext } from './context.ts' import { toStreamChunks } from './stream.ts' -/** Constructor options for {@link PiAiAdapter}. */ +/** Constructor options for {@link PiAiAdapter}: the two resolution seams the plugin owns. */ export interface PiAiAdapterOptions { - /** Validated provider profiles this adapter instance owns. */ - profiles: readonly PiAiProviderProfile[] + /** Current validated profiles by provider route; called once per operation. */ + profiles: () => ReadonlyMap + /** + * 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, 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: (provider: string, profile: ResolvedPiAiProviderProfile) => Promise } /** @@ -46,7 +53,7 @@ export interface PiAiAdapterOptions { * override, preserving the catalog's API/capability/compatibility metadata. */ function resolvePiModel( - profile: Omit, + profile: ResolvedPiAiProviderProfile, modelId: string, ): Model { const model = getBuiltinModels(profile.provider as BuiltinProvider).find(candidate => candidate.id === modelId) as Model | undefined @@ -58,12 +65,13 @@ function resolvePiModel( /** Copy profile stream knobs into pi-ai's common option vocabulary. */ function profileOptions( - profile: Omit, + profile: ResolvedPiAiProviderProfile, reasoning: ModelThinkingLevel | undefined, + apiKey: string | undefined, ): SimpleStreamOptions { const enabledReasoning: ThinkingLevel | undefined = reasoning === 'off' ? undefined : reasoning return { - ...profile.apiKey === undefined ? {} : { apiKey: profile.apiKey }, + ...apiKey === undefined ? {} : { apiKey }, ...enabledReasoning === undefined ? {} : { reasoning: enabledReasoning }, ...profile.thinkingBudgets === undefined ? {} : { thinkingBudgets: profile.thinkingBudgets }, ...profile.cacheRetention === undefined ? {} : { cacheRetention: profile.cacheRetention }, @@ -104,19 +112,16 @@ function requestHeaders(headers: Readonly> | undefined): * request, so models need not be registered during the Cordis lifecycle. */ export class PiAiAdapter extends LlmAdapter { - private readonly profiles: ReadonlyMap - - constructor(options: PiAiAdapterOptions) { + constructor(private readonly config: PiAiAdapterOptions) { super() - this.profiles = new Map(resolveProfiles(options.profiles).map(profile => [profile.provider, profile])) } override providerRetryPolicy(provider: string): ResolvedRetryPolicy | undefined { - return this.profiles.get(provider)?.retryPolicy + return this.config.profiles().get(provider)?.retryPolicy } override listModels(provider: string): Promise { - const profile = this.profiles.get(provider) + const profile = this.config.profiles().get(provider) if (profile === undefined) { return Promise.reject(new LlmError(`pi-ai adapter does not own provider "${provider}"`, 'NO_ADAPTER')) } @@ -132,7 +137,7 @@ export class PiAiAdapter extends LlmAdapter { model: string, _signal?: AbortSignal, ): Promise { - const profile = this.profiles.get(provider) + const profile = this.config.profiles().get(provider) if (profile === undefined) { return Promise.reject(new LlmError( `pi-ai adapter does not own provider "${provider}"`, @@ -165,7 +170,10 @@ export class PiAiAdapter extends LlmAdapter { if (options.stop !== undefined) { throw new LlmError('llm-pi-ai does not support GenerateOptions.stop', 'UNSUPPORTED_OPTION') } - const profile = this.profiles.get(options.provider) + // One resolution per stream call: the profile snapshot 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. + const profile = this.config.profiles().get(options.provider) if (profile === undefined) { throw new LlmError(`pi-ai adapter does not own provider "${options.provider}"`, 'NO_ADAPTER') } @@ -174,6 +182,7 @@ export class PiAiAdapter extends LlmAdapter { model, options.reasoningEffort ?? profile.reasoning, ) + const apiKey = await this.config.resolveApiKey(options.provider, profile) const consumer = new AbortController() const upstream = options.signal === undefined @@ -184,7 +193,7 @@ export class PiAiAdapter extends LlmAdapter { try { const events = streamSimple(model, toPiContext(options), { - ...profileOptions(profile, reasoning), + ...profileOptions(profile, reasoning, apiKey), ...options.temperature === undefined ? {} : { temperature: options.temperature }, ...options.maxTokens === undefined ? {} : { maxTokens: options.maxTokens }, ...options.sessionId === undefined ? {} : { sessionId: String(options.sessionId) }, diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index 8c7da2badd..053d6d56e6 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -1,5 +1,7 @@ /** * Configuration schema and provider-profile validation for the pi-ai adapter. + * Profiles are a dict keyed by provider route, so the composition base and a + * user-settings layer merge per provider and the route set is structural. * * @module dsh-llm-pi-ai/config */ @@ -7,6 +9,8 @@ import { getBuiltinProviders } from '@earendil-works/pi-ai/providers/all' import type { CacheRetention, ModelThinkingLevel, ThinkingBudgets, Transport } from '@earendil-works/pi-ai' import z from 'schemastery' +import { credentialRef } from '@deepseek-ai/dsh-credentials' +import type { CredentialRef } from '@deepseek-ai/dsh-credentials' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm' import type { ResolvedRetryPolicy, RetryPolicyConfig } from '@deepseek-ai/dsh-llm' @@ -14,12 +18,12 @@ import type { ResolvedRetryPolicy, RetryPolicyConfig } from '@deepseek-ai/dsh-ll /** Default maximum idle interval while an adapter stream read is outstanding. */ export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000 -/** Configuration for one pi-ai provider route. */ +/** Configuration for one pi-ai provider route; the `providers` dict key IS the route. */ export interface PiAiProviderProfile { - /** pi-ai provider catalog name and Harness route key. */ - provider: string - /** Provider credential; when absent pi-ai uses its provider-native ambient discovery. */ + /** Literal provider credential; prefer {@link apiKeyEnv}. With both absent pi-ai uses its provider-native ambient discovery. */ apiKey?: string + /** Credential reference (environment-variable name) resolved per request through `ctx.credentials`. */ + apiKeyEnv?: string /** Override the selected catalog model's endpoint without changing its protocol metadata. */ baseURL?: string /** Provider request headers; Harness attribution wins reserved names. */ @@ -42,18 +46,26 @@ export interface PiAiProviderProfile { retryPolicy?: RetryPolicyConfig } -/** Validated profile with every adapter-owned default resolved. */ -export interface ResolvedPiAiProviderProfile extends Omit { +/** Validated profile with its route stamped and every adapter-owned default resolved. */ +export interface ResolvedPiAiProviderProfile extends Omit { + /** pi-ai provider catalog name and Harness route key (the configuration dict key). */ + provider: string + /** Validated credential reference, when one is configured. */ + apiKeyEnv?: CredentialRef /** Positive finite provider-idle interval after defaulting. */ streamIdleTimeoutMs: number /** Immutable retry policy captured with this provider route. */ retryPolicy: ResolvedRetryPolicy } -/** Plugin configuration: the non-empty provider profiles this instance owns. */ +/** Plugin configuration: the provider routes this instance owns. */ export interface Config { - /** Non-empty set of pi-ai provider routes this adapter instance owns. */ - providers: PiAiProviderProfile[] + /** + * pi-ai provider routes, keyed by provider. An empty (or omitted) dict is + * the dormant settings-driven posture: the adapter mounts with no routes + * and registers them the moment a settings section supplies profiles. + */ + providers?: Record } const thinkingBudgets = z.object({ @@ -64,8 +76,8 @@ const thinkingBudgets = z.object({ }) const profile = z.object({ - provider: z.string().required(), - apiKey: z.string(), + apiKey: z.string().role('secret'), + apiKeyEnv: z.string(), baseURL: z.string(), headers: z.dict(z.string()), reasoning: z.union(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']), @@ -80,54 +92,64 @@ const profile = z.object({ /** Runtime schema for {@link Config}. */ export const Config: z = z.object({ - providers: z.array(profile).required(), + providers: z.dict(profile).default({}), }) /** * Validate profiles against the installed pi-ai catalog and return a detached - * shallow copy suitable for adapter construction. - * @param profiles - configured provider profiles. + * route-keyed map suitable for per-request reads. This is the one explicit + * resolve step, so an omitted dict resolves to the empty (dormant) route set + * here rather than through a hidden fallback. + * @param providers - configured provider profiles keyed by route. * @returns validated profiles in configuration order. */ -export function resolveProfiles(profiles: readonly PiAiProviderProfile[]): ResolvedPiAiProviderProfile[] { - if (profiles.length === 0) throw new Error('llm-pi-ai: providers must contain at least one profile') +export function resolveProfiles( + providers: Readonly> | undefined, +): Map { + if (Array.isArray(providers)) { + throw new Error('llm-pi-ai: providers is now a dict keyed by provider route, not an array of profiles') + } + const entries = Object.entries(providers ?? {}) const supported = new Set(getBuiltinProviders()) - const seen = new Set() - return profiles.map((source) => { + const resolved = new Map() + for (const [provider, source] of entries) { const legacy = source as PiAiProviderProfile & { + provider?: unknown maxRetries?: unknown maxRetryDelayMs?: unknown } + if ('provider' in legacy) { + throw new Error('llm-pi-ai: the profile "provider" field moved to the providers dict key') + } if ('maxRetries' in legacy || 'maxRetryDelayMs' in legacy) { throw new Error('llm-pi-ai: maxRetries and maxRetryDelayMs were removed; compose agent recovery with dsh-llm-retry') } - if (source.provider.length === 0) throw new Error('llm-pi-ai: provider names must be non-empty') - if (!supported.has(source.provider)) throw new Error(`llm-pi-ai: unknown pi-ai provider "${source.provider}"`) - if (seen.has(source.provider)) throw new Error(`llm-pi-ai: duplicate provider profile "${source.provider}"`) + if (provider.length === 0) throw new Error('llm-pi-ai: provider names must be non-empty') + if (!supported.has(provider)) throw new Error(`llm-pi-ai: unknown pi-ai provider "${provider}"`) if (source.apiKey !== undefined && source.apiKey.trim().length === 0) { - throw new Error(`llm-pi-ai: provider "${source.provider}" has an empty apiKey; omit it to use ambient authentication`) + throw new Error(`llm-pi-ai: provider "${provider}" has an empty apiKey; omit it to use ambient authentication`) } if (source.baseURL !== undefined && source.baseURL.length === 0) { - throw new Error(`llm-pi-ai: provider "${source.provider}" has an empty baseURL`) + throw new Error(`llm-pi-ai: provider "${provider}" has an empty baseURL`) } const streamIdleTimeoutMs = source.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS if (!Number.isFinite(streamIdleTimeoutMs) || streamIdleTimeoutMs <= 0 || streamIdleTimeoutMs > MAX_TIMER_DELAY_MS) { throw new Error( - `llm-pi-ai: provider "${source.provider}" streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`, + `llm-pi-ai: provider "${provider}" streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`, ) } - seen.add(source.provider) - return { - ...source, + const { apiKeyEnv, retryPolicy, ...rest } = source + resolved.set(provider, { + ...rest, + provider, + ...apiKeyEnv === undefined ? {} : { apiKeyEnv: credentialRef(apiKeyEnv) }, streamIdleTimeoutMs, - retryPolicy: resolveRetryPolicy( - source.retryPolicy, - `llm-pi-ai: provider "${source.provider}" retryPolicy`, - ), - ...source.headers === undefined ? {} : { headers: { ...source.headers } }, - ...source.thinkingBudgets === undefined ? {} : { thinkingBudgets: { ...source.thinkingBudgets } }, - } - }) + retryPolicy: resolveRetryPolicy(retryPolicy, `llm-pi-ai: provider "${provider}" retryPolicy`), + ...rest.headers === undefined ? {} : { headers: { ...rest.headers } }, + ...rest.thinkingBudgets === undefined ? {} : { thinkingBudgets: { ...rest.thinkingBudgets } }, + }) + } + return resolved } diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index da104cb22d..4c610cae21 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -1,22 +1,27 @@ /** - * Generic pi-ai-backed LLM adapter plugin. One plugin instance registers an - * explicit set of provider profiles; requests select a profile by provider and - * resolve the model dynamically from pi-ai's installed catalog. + * Generic pi-ai-backed LLM adapter plugin. One plugin instance owns a dict of + * provider routes; requests select a profile by provider and resolve the + * model dynamically from pi-ai's installed catalog. Profile facts resolve per + * request over the optional `llm-pi-ai` user-settings section and the + * optional credential seam, so a changed key, endpoint, or knob reaches the + * next request without a restart; a changed *route set* (or a route's + * registration-captured retry policy) re-registers the same adapter instance + * in place. * * ```yaml * - id: llm * name: '@deepseek-ai/dsh-llm-pi-ai' * config: * providers: - * - provider: openai - * apiKey: !!js process.env.OPENAI_API_KEY + * openai: + * apiKeyEnv: OPENAI_API_KEY * retryPolicy: * mode: normal * maxRetries: 2 - * - provider: anthropic - * apiKey: !!js process.env.ANTHROPIC_API_KEY - * - provider: openrouter - * apiKey: !!js process.env.OPENROUTER_API_KEY + * anthropic: + * apiKeyEnv: ANTHROPIC_API_KEY + * openrouter: + * apiKeyEnv: OPENROUTER_API_KEY * baseURL: https://proxy.example.com/v1 * ``` * @@ -24,21 +29,123 @@ */ import type { Context } from 'cordis' -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' +import type { ResolvedPiAiProviderProfile } from './config.ts' export { PiAiAdapter } from './adapter.ts' export type { PiAiAdapterOptions } from './adapter.ts' export { Config } from './config.ts' -export type { PiAiProviderProfile } from './config.ts' +export type { PiAiProviderProfile, ResolvedPiAiProviderProfile } from './config.ts' export const name = 'llm-pi-ai' export const inject = ['llm'] +const NS = settingsNamespace('llm-pi-ai') + +/** + * 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): unknown { + 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. */ export function apply(ctx: Context, config: Config): void { - const profiles = resolveProfiles(config.providers) - const adapter = new PiAiAdapter({ profiles: config.providers }) - ctx.llm.registerAdapter(profiles.map(entry => entry.provider), adapter) + let current: () => Config = () => config + let lastRaw: Config | undefined + let lastGood: ReadonlyMap | undefined + const profiles = (): ReadonlyMap => { + const raw = current() + if (raw === lastRaw && lastGood !== undefined) return lastGood + try { + const next = resolveProfiles(raw.providers) + lastRaw = raw + lastGood = next + return next + } catch (error) { + // Static composition resolves before anything registers, so this branch + // only sees a live settings snapshot failing catalog or bound checks: + // keep serving the last good profiles and say so once per bad snapshot. + if (lastGood === undefined) throw error + lastRaw = raw + ctx.logger.error('llm-pi-ai: keeping the last good profiles after an invalid settings section') + ctx.logger.error(error) + return lastGood + } + } + profiles() + + const resolveApiKey = async ( + provider: string, + profile: ResolvedPiAiProviderProfile, + ): Promise => { + 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') + 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 }) + // Route effects bind to this apply fiber via the stable `ctx` reference, + // 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 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, 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 (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() + + installSettingsSection(ctx, NS, Config, config, { + setSource: (source) => { + current = source + }, + onChange: ensureRegistrationFacts, + }) } diff --git a/packages/llm/llm-pi-ai/tests/adapter.e2e.ts b/packages/llm/llm-pi-ai/tests/adapter.e2e.ts index 352f2067d8..a3a949fea2 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.e2e.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.e2e.ts @@ -23,12 +23,13 @@ async function harness(_model: string, config: Partial = {} contexts.push(ctx) await ctx.plugin(LlmService) await ctx.plugin(LlmPiAi, { - providers: [{ - provider: 'deepseek', - ...process.env.DEEPSEEK_API_KEY === undefined ? {} : { apiKey: process.env.DEEPSEEK_API_KEY }, - ...process.env.DEEPSEEK_BASE_URL === undefined ? {} : { baseURL: process.env.DEEPSEEK_BASE_URL }, - ...config, - }], + providers: { + deepseek: { + ...process.env.DEEPSEEK_API_KEY === undefined ? {} : { apiKey: process.env.DEEPSEEK_API_KEY }, + ...process.env.DEEPSEEK_BASE_URL === undefined ? {} : { baseURL: process.env.DEEPSEEK_BASE_URL }, + ...config, + }, + }, }) return ctx } diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index cb70b6d3b8..a0826b3571 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -1,5 +1,3 @@ -import { createServer } from 'node:http' -import type { IncomingMessage, Server, ServerResponse } from 'node:http' import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import LlmService, { createUserMessage, CONTEXT_WINDOW_EXCEEDED_CODE, LlmError, ReasoningEffortId, userAgent } from '@deepseek-ai/dsh-llm' @@ -9,94 +7,30 @@ import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { getBuiltinModels } from '@earendil-works/pi-ai/providers/all' import { resolveProfiles } from '../src/config.ts' import { assemble } from './assemble.ts' - -interface MockServer { - url: string - paths: string[] - requests: unknown[] - headers: IncomingMessage['headers'][] - readonly closedResponses: number - responseClosed: Promise -} - -const servers: Server[] = [] +import { closeMockServers, mockServer, textEvents } from './mock-server.ts' afterEach(async () => { vi.unstubAllEnvs() - await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve)))) + await closeMockServers() }) -async function mockServer(script: { - status?: number - events?: string[] - body?: string - delayMs?: number - headers?: Record -}[]): Promise { - const paths: string[] = [] - const requests: unknown[] = [] - const headers: IncomingMessage['headers'][] = [] - let closedResponses = 0 - const responseClosed = Promise.withResolvers() - const server = createServer((request: IncomingMessage, response: ServerResponse) => { - response.on('close', () => { - closedResponses += 1 - responseClosed.resolve(undefined) - }) - let body = '' - request.on('data', (chunk: Buffer) => { body += chunk.toString('utf8') }) - request.on('end', () => { - paths.push(request.url ?? '') - requests.push(body.length === 0 ? undefined : JSON.parse(body)) - headers.push(request.headers) - const behavior = script.shift() ?? { status: 500, body: 'script exhausted' } - if (behavior.status !== undefined && behavior.status !== 200) { - response.writeHead(behavior.status, { 'content-type': 'application/json', ...behavior.headers }) - response.end(behavior.body ?? '{}') - return - } - response.writeHead(200, { 'content-type': 'text/event-stream' }) - let index = 0 - const writeNext = (): void => { - const event = behavior.events?.[index++] - if (event === undefined) { response.end(); return } - response.write(`data: ${event}\n\n`) - if (behavior.delayMs === undefined) writeNext() - else setTimeout(writeNext, behavior.delayMs) - } - writeNext() - }) - }) - servers.push(server) - await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) - const address = server.address() - if (address === null || typeof address === 'string') throw new Error('no port') - return { - url: `http://127.0.0.1:${address.port}`, - paths, - requests, - headers, - responseClosed: responseClosed.promise, - get closedResponses() { return closedResponses }, - } -} - -const textEvents = [ - '{"choices":[{"delta":{"role":"assistant","content":""},"index":0,"finish_reason":null}]}', - '{"choices":[{"delta":{"content":"hello"},"index":0,"finish_reason":null}]}', - '{"choices":[{"delta":{},"index":0,"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}', - '[DONE]', -] - async function harness(baseURL: string, overrides: Record = {}): Promise { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(LlmPiAi, { - providers: [{ provider: 'deepseek', apiKey: 'test-key', baseURL, ...overrides }], + providers: { deepseek: { apiKey: 'test-key', baseURL, ...overrides } }, }) return ctx } +/** Direct adapter over the real profile resolver, with literal-key resolution. */ +function adapterOf(providers: Record): PiAiAdapter { + return new PiAiAdapter({ + profiles: () => resolveProfiles(providers), + resolveApiKey: (_provider, profile) => Promise.resolve(profile.apiKey), + }) +} + describe('PiAiAdapter provider routing', () => { it('resolves a catalog model dynamically and uses a private endpoint', async () => { const server = await mockServer([{ events: textEvents }]) @@ -182,8 +116,8 @@ describe('PiAiAdapter provider routing', () => { const server = await mockServer([{ events: textEvents }]) const ctx = new Context() await ctx.plugin(LlmService) - ctx.llm.registerAdapter(['deepseek'], new PiAiAdapter({ - profiles: [{ provider: 'deepseek', apiKey: 'test-key', baseURL: server.url }], + ctx.llm.registerAdapter(['deepseek'], adapterOf({ + deepseek: { apiKey: 'test-key', baseURL: server.url }, })) const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) @@ -212,7 +146,7 @@ describe('PiAiAdapter provider routing', () => { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(LlmPiAi, { - providers: [{ provider: 'openai', apiKey: 'test-key', baseURL: `${server.url}/v1` }], + providers: { openai: { apiKey: 'test-key', baseURL: `${server.url}/v1` } }, }) const result = await assemble(ctx, { provider: 'openai', model: 'gpt-4.1', messages: [] }) expect(result.finish.kind).toBe('error') @@ -232,7 +166,7 @@ describe('PiAiAdapter provider routing', () => { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(LlmPiAi, { - providers: [{ provider: 'openai', apiKey: 'test-key', baseURL: `${server.url}/v1` }], + providers: { openai: { apiKey: 'test-key', baseURL: `${server.url}/v1` } }, }) const result = await assemble(ctx, { provider: 'openai', model: 'gpt-4.1', messages: [] }) @@ -246,12 +180,13 @@ describe('PiAiAdapter provider routing', () => { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(LlmPiAi, { - providers: [{ - provider: 'openai', - apiKey: 'test-key', - baseURL: `${server.url}/api/projects/openai/openai/v1`, - headers: { 'api-key': 'test-key', Authorization: '' }, - }], + providers: { + openai: { + apiKey: 'test-key', + baseURL: `${server.url}/api/projects/openai/openai/v1`, + headers: { 'api-key': 'test-key', Authorization: '' }, + }, + }, }) const result = await assemble(ctx, { provider: 'openai', model: 'gpt-5.5', messages: [] }) expect(result.finish.kind).toBe('error') @@ -333,16 +268,15 @@ describe('provider profile lifecycle', () => { const ctx = new Context() await ctx.plugin(LlmService) const fiber = await ctx.plugin(LlmPiAi, { - providers: [ - { - provider: 'openai', + providers: { + openai: { retryPolicy: { mode: 'always', backoff: { initialDelayMs: 25, maxDelayMs: 100, jitterRatio: 0.2 }, }, }, - { provider: 'anthropic' }, - ], + anthropic: {}, + }, }) expect(ctx.llm.listProviders()).toEqual([ { id: 'openai', name: 'openai' }, @@ -365,7 +299,7 @@ describe('provider profile lifecycle', () => { it('exposes the installed pi-ai model catalog through provider-neutral metadata', async () => { const ctx = new Context() await ctx.plugin(LlmService) - await ctx.plugin(LlmPiAi, { providers: [{ provider: 'openai' }] }) + await ctx.plugin(LlmPiAi, { providers: { openai: {} } }) const models = await ctx.llm.listModels('openai') expect(models.find(model => model.id === 'gpt-4.1')).toEqual({ provider: 'openai', id: 'gpt-4.1', name: 'GPT-4.1', @@ -379,7 +313,7 @@ describe('provider profile lifecycle', () => { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(LlmPiAi, { - providers: [{ provider: 'deepseek' }, { provider: 'openai' }], + providers: { deepseek: {}, openai: {} }, }) await expect(ctx.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash')) @@ -413,7 +347,7 @@ describe('provider profile lifecycle', () => { const supported = new Context() await supported.plugin(LlmService) await supported.plugin(LlmPiAi, { - providers: [{ provider: 'deepseek', reasoning: 'max' }], + providers: { deepseek: { reasoning: 'max' } }, }) await expect(supported.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash')) .resolves.toMatchObject({ reasoning: { defaultEffort: ReasoningEffortId('max') } }) @@ -421,7 +355,7 @@ describe('provider profile lifecycle', () => { const unsupported = new Context() await unsupported.plugin(LlmService) await unsupported.plugin(LlmPiAi, { - providers: [{ provider: 'deepseek', reasoning: 'medium' }], + providers: { deepseek: { reasoning: 'medium' } }, }) await expect(unsupported.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash')) .rejects.toMatchObject({ code: 'UNSUPPORTED_REASONING_EFFORT' }) @@ -429,7 +363,7 @@ describe('provider profile lifecycle', () => { const disabled = new Context() await disabled.plugin(LlmService) await disabled.plugin(LlmPiAi, { - providers: [{ provider: 'deepseek', reasoning: 'off' }], + providers: { deepseek: { reasoning: 'off' } }, }) await expect(disabled.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash')) .resolves.toMatchObject({ reasoning: { defaultEffort: ReasoningEffortId('off') } }) @@ -443,24 +377,53 @@ describe('provider profile lifecycle', () => { expect(server.headers[0]?.authorization).toBe('Bearer ambient-key') }) - it('validates empty, duplicate, unknown, and explicitly blank profiles', () => { - expect(() => resolveProfiles([])).toThrow(/at least one/) - expect(() => resolveProfiles([{ provider: '' }])).toThrow(/non-empty/) - expect(() => resolveProfiles([{ provider: 'not-real' }])).toThrow(/unknown/) - expect(() => resolveProfiles([{ provider: 'openai' }, { provider: 'openai' }])).toThrow(/duplicate/) - expect(() => resolveProfiles([{ provider: 'openai', apiKey: '' }])).toThrow(/empty apiKey/) - expect(() => resolveProfiles([{ provider: 'openai', apiKey: ' ' }])).toThrow(/empty apiKey/) - expect(() => resolveProfiles([{ provider: 'openai', baseURL: '' }])).toThrow(/empty baseURL/) + it('falls back to the ambient environment for apiKeyEnv without the credentials seam', async () => { + vi.stubEnv('PI_CUSTOM_REF_KEY', 'custom-ref-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 custom-ref-key') + }) + + 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 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', () => { + // Empty and omitted dicts are the dormant zero-route posture, not errors. + expect(resolveProfiles({}).size).toBe(0) + expect(resolveProfiles(undefined).size).toBe(0) + expect(() => resolveProfiles({ '': {} })).toThrow(/non-empty/) + expect(() => resolveProfiles({ 'not-real': {} })).toThrow(/unknown/) + // The pre-release array shape and its per-profile provider field fail + // loud with migration directions instead of half-working. + expect(() => resolveProfiles([{ provider: 'openai' }] as never)).toThrow(/dict keyed by provider/) + expect(() => resolveProfiles({ openai: { provider: 'openai' } as never })).toThrow(/moved to the providers dict key/) + expect(() => resolveProfiles({ openai: { apiKey: '' } })).toThrow(/empty apiKey/) + expect(() => resolveProfiles({ openai: { apiKey: ' ' } })).toThrow(/empty apiKey/) + expect(() => resolveProfiles({ openai: { baseURL: '' } })).toThrow(/empty baseURL/) + expect(() => resolveProfiles({ openai: { apiKeyEnv: 'not-a-var!' } })).toThrow(/must match/) }) it.each(['maxRetries', 'maxRetryDelayMs'] as const)( 'rejects removed profile field %s instead of silently restoring hidden SDK retries', async (field) => { - const legacy = { provider: 'openai', [field]: 2 } - expect(() => resolveProfiles([legacy as never])).toThrow(/removed.*agent recovery/i) + const legacy = { [field]: 2 } + expect(() => resolveProfiles({ openai: legacy })).toThrow(/removed.*agent recovery/i) const ctx = new Context() await ctx.plugin(LlmService) - await expect(ctx.plugin(LlmPiAi, { providers: [legacy as never] })) + await expect(ctx.plugin(LlmPiAi, { providers: { openai: legacy } })) .rejects.toThrow(/removed.*agent recovery/i) }, ) @@ -476,30 +439,26 @@ describe('provider profile lifecycle', () => { for (const entry of invalid) { const ctx = new Context() await ctx.plugin(LlmService) - await expect(ctx.plugin(LlmPiAi, { providers: [{ provider: 'openai', ...entry }] })) + await expect(ctx.plugin(LlmPiAi, { providers: { openai: { ...entry } } })) .rejects.toThrow() } }) it('rejects invalid nested retryPolicy at the provider-profile boundary', async () => { - expect(() => resolveProfiles([{ - provider: 'openai', - retryPolicy: { mode: 'always', backoff: { jitterRatio: -1 } }, - }])).toThrow(/retryPolicy\.backoff\.jitterRatio/) + expect(() => resolveProfiles({ + openai: { retryPolicy: { mode: 'always', backoff: { jitterRatio: -1 } } }, + })).toThrow(/retryPolicy\.backoff\.jitterRatio/) const ctx = new Context() await ctx.plugin(LlmService) await expect(ctx.plugin(LlmPiAi, { - providers: [{ - provider: 'openai', - retryPolicy: { mode: 'normal', maxRetries: -1 }, - }], + providers: { openai: { retryPolicy: { mode: 'normal', maxRetries: -1 } } }, })).rejects.toThrow(/retryPolicy/) expect(ctx.llm.listProviders()).toEqual([]) }) it('constructs the adapter directly and rejects routes it does not own', async () => { - const adapter = new PiAiAdapter({ profiles: [{ provider: 'openai' }] }) + const adapter = adapterOf({ openai: {} }) await expect(adapter.listModels('anthropic')).rejects.toMatchObject({ code: 'NO_ADAPTER' }) await expect(adapter.resolveModel('anthropic', 'claude-sonnet-4')) .rejects.toMatchObject({ code: 'NO_ADAPTER' }) @@ -511,12 +470,12 @@ describe('provider profile lifecycle', () => { expect(new LlmError('x', 'X')).toBeInstanceOf(Error) }) - it('validates direct-constructor profiles at the embedding boundary', () => { - expect(() => new PiAiAdapter({ - profiles: [{ provider: 'openai', streamIdleTimeoutMs: 0 }], + it('validates profiles at the shared resolver boundary', () => { + expect(() => resolveProfiles({ + openai: { streamIdleTimeoutMs: 0 }, })).toThrow(/streamIdleTimeoutMs.*positive finite/) - expect(() => new PiAiAdapter({ - profiles: [{ provider: 'openai', streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1 }], + expect(() => resolveProfiles({ + openai: { streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1 }, })).toThrow(/streamIdleTimeoutMs.*no greater/) }) }) @@ -527,7 +486,7 @@ describe('abort wiring', () => { const message = Object.defineProperty({}, 'role', { get() { throw original }, }) - const adapter = new PiAiAdapter({ profiles: [{ provider: 'deepseek', apiKey: 'test-key' }] }) + const adapter = adapterOf({ deepseek: { apiKey: 'test-key' } }) const drain = async (): Promise => { for await (const _chunk of adapter.stream({ provider: 'deepseek', @@ -548,7 +507,7 @@ describe('abort wiring', () => { throw original }, }) - const adapter = new PiAiAdapter({ profiles: [{ provider: 'deepseek', apiKey: 'test-key' }] }) + const adapter = adapterOf({ deepseek: { apiKey: 'test-key' } }) const drain = async (): Promise => { for await (const _chunk of adapter.stream({ provider: 'deepseek', @@ -562,7 +521,7 @@ describe('abort wiring', () => { }) it('resolves catalog endpoints without an override before honoring pre-abort', async () => { - const adapter = new PiAiAdapter({ profiles: [{ provider: 'deepseek', apiKey: 'test-key' }] }) + const adapter = adapterOf({ deepseek: { apiKey: 'test-key' } }) const controller = new AbortController() controller.abort('already stopped') const chunks = [] diff --git a/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts new file mode 100644 index 0000000000..598d2aa2a9 --- /dev/null +++ b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts @@ -0,0 +1,189 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +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' +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' + +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 { + throw new Error('stub adapter must never stream') + } +} + +const cleanups: Array<() => Promise> = [] + +afterEach(async () => { + while (cleanups.length > 0) await cleanups.pop()!() + await closeMockServers() + vi.unstubAllEnvs() +}) + +async function home(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'dsh-pi-dynamic-')) + cleanups.push(() => rm(dir, { recursive: true, force: true })) + return dir +} + +/** Real dynamic composition mirroring the deepseek twin's harness. */ +async function boot(dir: string, config: LlmPiAi.Config): Promise { + const ctx = new Context() + cleanups.push(async () => { + await ctx.fiber.dispose() + }) + await ctx.plugin(LlmService) + await ctx.plugin(SettingsLocal, { path: join(dir, 'settings.yaml'), watch: false }) + await ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false }) + await ctx.plugin(LlmPiAi, config) + return ctx +} + +describe('request-level dynamic profiles', () => { + it('mounts bare and dormant, then registers routes the moment settings supply providers', async () => { + vi.stubEnv('PI_DYNAMIC_KEY', '') + const dir = await home() + await writeFile(join(dir, '.env'), 'PI_DYNAMIC_KEY=pk-from-settings\n') + const server = await mockServer([{ events: textEvents }]) + // The exact product posture: `- id: llm-pi-ai` with no config at all. + const ctx = await boot(dir, {}) + + expect(ctx.llm.listProviders()).toEqual([]) + await ctx.settings.update(NS, { + providers: { deepseek: { apiKeyEnv: 'PI_DYNAMIC_KEY', baseURL: server.url } }, + }) + expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['deepseek']) + await expect(ctx.llm.listModels('deepseek')).resolves.not.toHaveLength(0) + + 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 pk-from-settings') + + // Emptying the user layer returns the adapter to its dormant state. + await ctx.settings.replace(NS, {}) + expect(ctx.llm.listProviders()).toEqual([]) + }) + + it('adds a provider route from settings and drops it when the user layer resets', async () => { + const dir = await home() + const server = await mockServer([{ events: textEvents }]) + const ctx = await boot(dir, { + providers: { openai: { apiKey: 'k', baseURL: 'http://127.0.0.1:1/v1' } }, + }) + + expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai']) + await ctx.settings.update(NS, { + providers: { deepseek: { apiKey: 'live-key', baseURL: server.url } }, + }) + expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai', 'deepseek']) + + 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 live-key') + + // Reset the user layer: the settings-born route unregisters, the + // composition route stays. + await ctx.settings.replace(NS, {}) + expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai']) + await expect(assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] })) + .rejects.toMatchObject({ code: 'NO_ADAPTER' }) + }) + + it('rotates the per-request credential referenced by apiKeyEnv', async () => { + vi.stubEnv('PI_DYNAMIC_KEY', '') + const dir = await home() + await writeFile(join(dir, '.env'), 'PI_DYNAMIC_KEY=pk-one\n') + const server = await mockServer([{ events: textEvents }, { events: textEvents }]) + const ctx = await boot(dir, { + providers: { deepseek: { apiKeyEnv: 'PI_DYNAMIC_KEY', baseURL: server.url } }, + }) + + await assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] }) + expect(server.headers[0]?.authorization).toBe('Bearer pk-one') + + await ctx.credentials.set(credentialRef('PI_DYNAMIC_KEY'), 'pk-two') + await assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] }) + expect(server.headers[1]?.authorization).toBe('Bearer pk-two') + }) + + it('re-registers routes in place when a captured retry policy changes', async () => { + const dir = await home() + const ctx = await boot(dir, { providers: { openai: {} } }) + + await ctx.settings.update(NS, { + providers: { + openai: { + retryPolicy: { mode: 'always', backoff: { initialDelayMs: 25, maxDelayMs: 100, jitterRatio: 0.2 } }, + }, + }, + }) + expect(ctx.llm.providerRetryPolicy('openai')).toEqual({ + mode: 'always', + initialDelayMs: 25, + maxDelayMs: 100, + jitterRatio: 0.2, + }) + expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai']) + }) + + it('keeps the last good profiles when a settings snapshot names an unknown provider', async () => { + const dir = await home() + const ctx = await boot(dir, { providers: { openai: {} } }) + + // Schema-valid but catalog-invalid: the resolver rejects it and the + // last good route set keeps serving. + 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) + }) +}) diff --git a/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts b/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts new file mode 100644 index 0000000000..460e78b7c2 --- /dev/null +++ b/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts @@ -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([ + ['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 + 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') + }) +}) diff --git a/packages/llm/llm-pi-ai/tests/mock-server.ts b/packages/llm/llm-pi-ai/tests/mock-server.ts new file mode 100644 index 0000000000..573c61a9a2 --- /dev/null +++ b/packages/llm/llm-pi-ai/tests/mock-server.ts @@ -0,0 +1,82 @@ +import { createServer } from 'node:http' +import type { IncomingMessage, Server, ServerResponse } from 'node:http' + +export interface MockServer { + url: string + paths: string[] + requests: unknown[] + headers: IncomingMessage['headers'][] + readonly closedResponses: number + responseClosed: Promise +} + +const servers: Server[] = [] + +/** Close every server opened since the last call; run from each spec's afterEach. */ +export async function closeMockServers(): Promise { + await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve)))) +} + +/** A minimal complete text generation in pi-ai's chat-completions shape. */ +export const textEvents = [ + '{"choices":[{"delta":{"role":"assistant","content":""},"index":0,"finish_reason":null}]}', + '{"choices":[{"delta":{"content":"hello"},"index":0,"finish_reason":null}]}', + '{"choices":[{"delta":{},"index":0,"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}', + '[DONE]', +] + +/** Local provider stand-in: replays scripted behaviors per request. */ +export async function mockServer(script: { + status?: number + events?: string[] + body?: string + delayMs?: number + headers?: Record +}[]): Promise { + const paths: string[] = [] + const requests: unknown[] = [] + const headers: IncomingMessage['headers'][] = [] + let closedResponses = 0 + const responseClosed = Promise.withResolvers() + const server = createServer((request: IncomingMessage, response: ServerResponse) => { + response.on('close', () => { + closedResponses += 1 + responseClosed.resolve(undefined) + }) + let body = '' + request.on('data', (chunk: Buffer) => { body += chunk.toString('utf8') }) + request.on('end', () => { + paths.push(request.url ?? '') + requests.push(body.length === 0 ? undefined : JSON.parse(body)) + headers.push(request.headers) + const behavior = script.shift() ?? { status: 500, body: 'script exhausted' } + if (behavior.status !== undefined && behavior.status !== 200) { + response.writeHead(behavior.status, { 'content-type': 'application/json', ...behavior.headers }) + response.end(behavior.body ?? '{}') + return + } + response.writeHead(200, { 'content-type': 'text/event-stream' }) + let index = 0 + const writeNext = (): void => { + const event = behavior.events?.[index++] + if (event === undefined) { response.end(); return } + response.write(`data: ${event}\n\n`) + if (behavior.delayMs === undefined) writeNext() + else setTimeout(writeNext, behavior.delayMs) + } + writeNext() + }) + }) + servers.push(server) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('no port') + return { + url: `http://127.0.0.1:${address.port}`, + paths, + requests, + headers, + responseClosed: responseClosed.promise, + get closedResponses() { return closedResponses }, + } +} diff --git a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts index 0d08e9b93d..f59859bfad 100644 --- a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts +++ b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts @@ -43,12 +43,11 @@ async function harness(): Promise { contexts.push(ctx) await ctx.plugin(LlmService) await ctx.plugin(LlmPiAi, { - providers: providerCases.map(profile => ({ - provider: profile.provider, + providers: Object.fromEntries(providerCases.map(profile => [profile.provider, { ...profile.apiKey === undefined ? {} : { apiKey: profile.apiKey }, ...profile.baseURL === undefined ? {} : { baseURL: profile.baseURL }, ...profile.headers === undefined ? {} : { headers: profile.headers }, - })), + }])), }) return ctx } diff --git a/packages/llm/llm-pi-ai/tests/sdk-options.spec.ts b/packages/llm/llm-pi-ai/tests/sdk-options.spec.ts index d96297c242..3f12ef4460 100644 --- a/packages/llm/llm-pi-ai/tests/sdk-options.spec.ts +++ b/packages/llm/llm-pi-ai/tests/sdk-options.spec.ts @@ -10,6 +10,7 @@ vi.mock('@earendil-works/pi-ai/compat', async (importOriginal) => { }) import { PiAiAdapter } from '../src/adapter.ts' +import { resolveProfiles } from '../src/config.ts' afterEach(() => { streamSimple.mockReset() }) @@ -21,7 +22,10 @@ describe('pi-ai SDK retry boundary', () => { throw failure }, }) - const adapter = new PiAiAdapter({ profiles: [{ provider: 'openai', apiKey: 'test-key' }] }) + const adapter = new PiAiAdapter({ + profiles: () => resolveProfiles({ openai: { apiKey: 'test-key' } }), + resolveApiKey: () => Promise.resolve('test-key'), + }) const drain = async (): Promise => { for await (const _chunk of adapter.stream({ provider: 'openai', diff --git a/packages/llm/llm-pi-ai/tsconfig.json b/packages/llm/llm-pi-ai/tsconfig.json index 45c2af21a5..ee8a81e73b 100644 --- a/packages/llm/llm-pi-ai/tsconfig.json +++ b/packages/llm/llm-pi-ai/tsconfig.json @@ -20,6 +20,12 @@ { "path": "../../llm/llm" }, + { + "path": "../../credentials/credentials" + }, + { + "path": "../../settings/settings" + }, { "path": "../../support/invariants" }, diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml index 49ff6d7c48..d7740dcebf 100644 --- a/packages/llm/llm/README.i18n.yaml +++ b/packages/llm/llm/README.i18n.yaml @@ -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: d343449d1530bf70a3a8c57f883894e29c42d18f -README.zh.md: 4dc4a0ca06378116d05fdb4b9b048738930511fd +README.md: 5b0c1b2dcafeefaad25f1714e4a1783430370118 +README.zh.md: 5f5c8142ec829e8ca8cfd40e6caa341ae0a33c7d diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index d343449d15..5b0c1b2dca 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -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.providerRetryPolicy(provider: string): ResolvedRetryPolicy` Return the provider-owned retry policy captured during registration, with normal defaults resolved. - `ctx.llm.listModels(provider: string): Promise` Discover the models one registered provider currently advertises. diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md index 4dc4a0ca06..5f5c8142ec 100644 --- a/packages/llm/llm/README.zh.md +++ b/packages/llm/llm/README.zh.md @@ -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.providerRetryPolicy(provider: string): ResolvedRetryPolicy` 返回注册时捕获的提供方重试策略,并解析 normal 默认值。 - `ctx.llm.listModels(provider: string): Promise` 发现某个已注册提供方当前公布的模型。 diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 1fb4443af0..3759f9f8df 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -184,6 +184,30 @@ export abstract class LlmAdapter { abstract stream(options: GenerateOptions): AsyncIterable } +/** + * 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. + * + * Throws `LlmError` with code `REGISTRATION_DISPOSED` once the registration + * has been released: its routes are gone and its disposer has already run, + * so anything registered afterwards would have no owner left to release it. + * @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. @@ -201,39 +225,79 @@ 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() + // The disposer has run: `owned` being empty cannot say so on its own, + // because `replace([])` legally leaves a live registration holding none. + let released = false 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() - 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.commitRoutes(owned, this.prepareRoutes(providers, adapter, owned)) yield () => { - for (const provider of providers) this.adapters.delete(provider) + released = true + for (const provider of owned) this.adapters.delete(provider) + owned.clear() } }.bind(this), 'llm.registerAdapter()') // ctx.effect's disposer returns Promise; 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 => { + // Registering here would leak: the effect's disposer already ran, so + // nothing remains to release whatever this call would put in the map. + if (released) { + throw new LlmError('a disposed adapter registration cannot replace its routes', 'REGISTRATION_DISPOSED') + } + 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): AdapterRegistration[] { + const unique = new Set() + 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. + */ + private commitRoutes(owned: Set, 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) + } } /** diff --git a/packages/llm/llm/tests/service.spec.ts b/packages/llm/llm/tests/service.spec.ts index 1afb3c4b6d..f723c7f474 100644 --- a/packages/llm/llm/tests/service.spec.ts +++ b/packages/llm/llm/tests/service.spec.ts @@ -1381,4 +1381,32 @@ describe('LlmService', () => { disposeAgain() expect(ctx.llm.listProviders()).toEqual([]) }) + + it('refuses to replace routes on a registration that was already released', async () => { + // The leak this prevents: the effect's disposer has run, so a route added + // afterwards would sit in the registry with nothing left to release it. + const ctx = new Context() + await ctx.plugin(LlmService) + + const handle = ctx.llm.registerAdapter(['m1'], new ScriptedAdapter(SCRIPT)) + handle() + expect(() => { handle.replace(['leaked']) }) + .toThrow(/disposed adapter registration cannot replace its routes/) + expect(ctx.llm.listProviders()).toEqual([]) + }) + + it('still allows an empty route set on a live registration', async () => { + // `replace([])` is the settings-section-emptied case: legal, and it must + // not be mistaken for disposal by the guard above. + const ctx = new Context() + await ctx.plugin(LlmService) + + const handle = ctx.llm.registerAdapter(['m1'], new ScriptedAdapter(SCRIPT)) + handle.replace([]) + expect(ctx.llm.listProviders()).toEqual([]) + handle.replace(['m2']) + expect(ctx.llm.listProviders()).toEqual([{ id: 'm2', name: 'm2' }]) + handle() + expect(ctx.llm.listProviders()).toEqual([]) + }) }) diff --git a/packages/settings/settings-local/package.json b/packages/settings/settings-local/package.json index aefb1ccd33..0040b65507 100644 --- a/packages/settings/settings-local/package.json +++ b/packages/settings/settings-local/package.json @@ -27,6 +27,7 @@ ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-atomic-write": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-paths": "^0.0.1", "@deepseek-ai/dsh-settings": "^0.0.1", @@ -38,6 +39,7 @@ "yaml": "^2.9.0" }, "devDependencies": { + "@deepseek-ai/dsh-atomic-write": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", diff --git a/packages/settings/settings-local/src/index.ts b/packages/settings/settings-local/src/index.ts index d0d1497b16..8043e6db45 100644 --- a/packages/settings/settings-local/src/index.ts +++ b/packages/settings/settings-local/src/index.ts @@ -10,10 +10,10 @@ import { Context, Service } from 'cordis' import z from 'schemastery' import { watch as chokidarWatch } from 'chokidar' -import { randomBytes } from 'node:crypto' -import { mkdir, readFile, rename, 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 { 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 = z.object({ @@ -197,8 +180,11 @@ export class SettingsLocal extends Settings { } private async persistSection(ns: SettingsNamespace, section: Record): Promise { - await mkdir(dirname(this.spec.filename), { recursive: true }) - await this.withWriterLock(async () => { + // The writer lock's exclusive create needs the parent to exist before + // writeFileAtomic gets its own chance to create it. + // 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 @@ -209,74 +195,14 @@ export class SettingsLocal extends Settings { const output = this.spec.format === 'yaml' ? this.renderYaml(ns, section) : this.renderJson(ns, section) - // Exclusive-create (`wx`) a random-suffix sibling: the open refuses to - // follow any planted symlink at a guessable temp path, and the fresh inode - // carries owner-only permissions that survive the rename — a document that - // may hold personal values is never world-readable and never a symlink. - const temp = `${this.spec.filename}.${randomBytes(6).toString('hex')}.tmp` - // TODO(settings-atomic-durability): Use a replacement that fsyncs the file - // and parent directory and preserves owner-only permissions on Windows. - try { - await writeFile(temp, output, { mode: 0o600, flag: 'wx' }) - await rename(temp, this.spec.filename) - } catch (error) { - await rm(temp, { force: true }) - throw error - } + // 0600: a document that may hold personal values is never world-readable. + 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 (`.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(operation: () => Promise): Promise { - 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) { - // TODO(settings-lock-ownership): Replace age-only takeover with ownership-safe - // acquisition and release so a slow writer cannot remove a successor's lock. + }, { + 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 { - 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> { diff --git a/packages/settings/settings-local/tsconfig.json b/packages/settings/settings-local/tsconfig.json index 67a746c982..cf5b68fc11 100644 --- a/packages/settings/settings-local/tsconfig.json +++ b/packages/settings/settings-local/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../../../vendor/schemastery" }, + { + "path": "../../util/atomic-write" + }, { "path": "../../util/paths" }, diff --git a/packages/settings/settings/src/index.ts b/packages/settings/settings/src/index.ts index 76c1082f3f..241bc41bfa 100644 --- a/packages/settings/settings/src/index.ts +++ b/packages/settings/settings/src/index.ts @@ -546,4 +546,80 @@ 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 { + /** + * Receive the active configuration source: the resolved settings scope + * while one is attached, the composition entry otherwise. Called before + * the matching `onChange` at attach and at detach. + * @param current - thunk returning the currently authoritative value. + */ + setSource(current: () => T): void + /** + * Re-judge anything derived from the source — registration-level facts, + * memoized resolutions — after an attach, a detach, or a committed change. + */ + onChange(): void +} + +/** + * Install the canonical optional-settings consumer wiring: while a settings + * service exists, register `ns` with the consumer's composition entry as the + * `base` layer and point the source thunk at the resolved scope; when the + * service goes away (disposal, provider reload), fall back to the entry so + * the consumer keeps working exactly as composed. The registration rides the + * scoped fiber, so no settings service ever mounted means none of this runs. + * @param ctx - consumer plugin context owning the wiring. + * @param ns - the consumer-owned settings namespace. + * @param schema - schema resolving the namespace (typically the plugin Config). + * @param entry - the consumer's composition entry config, used as `base`. + * @param hooks - source sink and change notification. + */ +export function installSettingsSection( + ctx: Context, + ns: SettingsNamespace, + schema: z, + entry: T, + hooks: SettingsSectionHooks, +): void { + ctx.inject(['settings'], (sctx) => { + 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() + }) + hooks.onChange() + scope.watch(() => { + // A stored change landing while the consumer unloads reaches the watcher + // before the registration is released, and `onChange` is exactly as + // harmful here as in the disposer above: it re-registers routes against + // a fiber whose resources are being let go. + if (isUnloading(ctx)) return + hooks.onChange() + }) + }) +} + export default Settings diff --git a/packages/settings/settings/tests/settings.spec.ts b/packages/settings/settings/tests/settings.spec.ts index 379ce02d08..9e3a8dd7e5 100644 --- a/packages/settings/settings/tests/settings.spec.ts +++ b/packages/settings/settings/tests/settings.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import z from 'schemastery' -import { Settings, deepEqualJson, settingsNamespace, type SettingsNamespace, type SettingsScope, type SettingsUpdateSource } from '../src/index.ts' +import { Settings, deepEqualJson, installSettingsSection, settingsNamespace, type SettingsNamespace, type SettingsScope, type SettingsUpdateSource } from '../src/index.ts' import { MemorySettings } from './memory.ts' /** A provider implementing only the three primitives: the seam owns init. */ @@ -652,3 +652,108 @@ describe('watch', () => { expect(scope.get()).toEqual({ theme: 'light', fontSize: 14 }) }) }) + +describe('installSettingsSection', () => { + const HelperSchema: z<{ theme: string }> = z.object({ + theme: z.string().default('default'), + }) + + it('drives the source through attach, live commits, and detach', async () => { + const ctx = new Context() + const entry = { theme: 'entry' } + let current: () => { theme: string } = () => entry + let changes = 0 + installSettingsSection(ctx, settingsNamespace('helper-ns'), HelperSchema, entry, { + setSource: (source) => { + current = source + }, + onChange: () => { + changes += 1 + }, + }) + // No settings service mounted: nothing ran, the entry stays authoritative. + expect(current()).toEqual({ theme: 'entry' }) + expect(changes).toBe(0) + + const fiber = ctx.plugin(MemorySettings, { doc: { 'helper-ns': { theme: 'user' } } }) + await fiber + await vi.waitFor(() => { + expect(current()).toEqual({ theme: 'user' }) + }) + expect(changes).toBe(1) + + await ctx.settings.update(settingsNamespace('helper-ns'), { theme: 'live' }) + await vi.waitFor(() => { + expect(changes).toBe(2) + }) + expect(current()).toEqual({ theme: 'live' }) + + await fiber.dispose() + await vi.waitFor(() => { + expect(changes).toBe(3) + }) + 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']) + }) + + it('stays silent for a stored change that lands while the consumer unloads', async () => { + // The watcher outlives the start of teardown by the width of the unload, + // so a document change arriving in that window reaches it. Notifying then + // is exactly as harmful as notifying from the disposer. + const { ctx, provider } = 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']) + }) + + const unloading = consumer.dispose() + provider.pushExternal({ 'helper-ns': { theme: 'racing' } }) + await unloading + expect(changes).toEqual(['user']) + }) +}) diff --git a/packages/support/loader-smoke/src/index.ts b/packages/support/loader-smoke/src/index.ts index e573684a4e..4ee5cfec7d 100644 --- a/packages/support/loader-smoke/src/index.ts +++ b/packages/support/loader-smoke/src/index.ts @@ -141,6 +141,13 @@ export interface LoaderSmokeOptions { readonly prepare?: (cwd: string) => Promise | void /** Optional world-state assertion run in the isolated cwd before cleanup. */ readonly inspect?: (cwd: string) => Promise | void + /** + * Exact process exit code this smoke expects; defaults to `0`. Scenarios + * pinning a designed failure surface (a one-shot turn ending in an error + * result) declare its nonzero exit here, and a run that exits any other + * way — including succeeding — still fails the smoke. + */ + readonly expectedExitCode?: number } /** Captured output from a Loader smoke that exited successfully. */ @@ -187,8 +194,9 @@ export async function runLoaderSmoke(options: LoaderSmokeOptions): Promise { libBinScript: fixture('fail'), configPath, tsconfigPath, - })).rejects.toThrow('failure fixture exited 7. stdout:\n\nstderr:\nfixture failed') + })).rejects.toThrow('failure fixture exited 7 (expected 0). stdout:\n\nstderr:\nfixture failed') + }) + + it('accepts a declared expected failure exit and rejects any other outcome', async () => { + // A scenario pinning a designed failure surface declares its exit code… + const declared = await runLoaderSmoke({ + label: 'declared failure fixture', + tempDirPrefix: 'loader-smoke-declared-fail-', + binScript: fixture('fail'), + libBinScript: fixture('fail'), + configPath, + tsconfigPath, + expectedExitCode: 7, + }) + expect(declared.stderr).toBe('fixture failed\n') + + // …and a run that succeeds instead still fails the smoke. + await expect(runLoaderSmoke({ + label: 'unexpectedly clean fixture', + tempDirPrefix: 'loader-smoke-clean-', + binScript: fixture('success'), + libBinScript: fixture('success'), + configPath, + tsconfigPath, + expectedExitCode: 7, + })).rejects.toThrow(/exited 0 \(expected 7\)/) }) it('kills a process at its deadline and reports captured output', async () => { diff --git a/packages/ui/app-boot/README.i18n.yaml b/packages/ui/app-boot/README.i18n.yaml index ab36ca58a5..c670928ba7 100644 --- a/packages/ui/app-boot/README.i18n.yaml +++ b/packages/ui/app-boot/README.i18n.yaml @@ -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: 4d8c7de65515a251f227075c7baf041fc1b210c8 -README.zh.md: d9b69a2b102a60685524288f75edeaabb21802db +README.md: 1beffd6fbff2b84202683b010cd104f7c84297c7 +README.zh.md: d9ce9774b9b492a98556bbd9aa4564b711dbe40e diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index 4d8c7de655..1beffd6fbf 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -27,8 +27,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 official `dsh` surfaces ([`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 the shipped surface overlays: 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 a silent no-op. 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 the shipped surface overlays: 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 a silent no-op. 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. diff --git a/packages/ui/app-boot/README.zh.md b/packages/ui/app-boot/README.zh.md index d9b69a2b10..d9ce9774b9 100644 --- a/packages/ui/app-boot/README.zh.md +++ b/packages/ui/app-boot/README.zh.md @@ -27,8 +27,8 @@ 开发者的机器本地偏好位于所有仓库之外的 Harness home 中(默认 `~/.dsh`,可由 `$DSH_HOME` 覆盖;统一由根级 [`resolveDshHome`](../../util/paths/README.md) 解析),并由官方 `dsh` 界面([`apps/cli`](../../../apps/cli/README.md))使用;demo bin 会原样启动仓库中提交的树。这里有两个可选文件: -- **`.env`**:在调用目录的 `.env` 之后加载;`process.loadEnvFile` 从不覆盖已有值,因此优先级为环境中的值 > 项目 `.env` > 个人 `.env`。 -- **`config.yaml`**:在发布的默认配置上应用 Loader overlay patch,语义与交付的 surface overlay 相同:按 id 定位的 patch 会替换对应条目的整个 `config`(未改字段也要重述),`insert` 会添加条目,`!!js` 表达式则在挂载时插值,因此个人 `apiKey` 可以引用个人 `.env`。如果 patch 指定的条目 id 不在已启动树中,则静默不执行任何操作。空文件或仅含注释的文件会抛出异常(其解析结果为空,而不是列表);如需禁用 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,语义与交付的 surface overlay 相同:按 id 定位的 patch 会替换对应条目的整个 `config`(未改字段也要重述),`insert` 会添加条目,`!!js` 表达式则在挂载时插值。如果 patch 指定的条目 id 不在已启动树中,则静默不执行任何操作。空文件或仅含注释的文件会抛出异常(其解析结果为空,而不是列表);如需禁用 overlay,请使用 `[]` 或删除该文件。 子进程测试 launcher 会把 `DSH_HOME` 指向逐测试隔离的目录,确保开发者的个人 overlay 不会泄漏到 fixture(测试前置数据)中。 diff --git a/packages/util/README.i18n.yaml b/packages/util/README.i18n.yaml index 8bd1ff35b2..add6070a27 100644 --- a/packages/util/README.i18n.yaml +++ b/packages/util/README.i18n.yaml @@ -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/util/README.md -README.md: 605c3dd0beebc16109e8e6bc944ea722a60975c0 -README.zh.md: 5c66ded33a36079f80965cf466449843e07511f0 +README.md: 46904aba70c7cf0f98bb75cce79d97bb12b950a9 +README.zh.md: 59a2dcf7926c12d7005446393cadfd8b0be88f77 diff --git a/packages/util/README.md b/packages/util/README.md index 605c3dd0be..46904aba70 100644 --- a/packages/util/README.md +++ b/packages/util/README.md @@ -10,6 +10,7 @@ Zero-dependency primitives shared across the other groups. A package lands here | `paths/` | Canonical single-root `DSH_HOME` resolution plus shared filesystem path constants and helpers for harness user data (no harness deps) | | `timeout/` | The timing/classification half of a timeout — `clampTimeout`/`deadline`/`timeoutOf`/`TimeoutReason` (pure functions, no harness deps); termination stays in each capability | | `retention/` | Bounded model-facing output — `ItemRetainer`/`TextRetainer` + neutral notice helpers (pure, no harness deps); business semantics stay in each tool | +| `atomic-write/` | Atomic file replacement — `writeFileAtomic` (exclusive-create temp + rename carrying the caller-stated mode); shared by the settings and credentials stores | | `native-command/` | No-shell `execFile` runner for host-native OS integrations — utf8 capture, abort propagation, Windows hide (no harness deps); command choice stays in each caller | `dsh-brand` is the canonical case: it owns ONLY the `Branded` helper, so a capability package can brand the ids it owns (`dsh-tasks`'s `TaskId`, `dsh-session`'s `SessionId`, …) by depending on `dsh-brand` alone, without pulling in an unrelated package just to reach `Branded`. diff --git a/packages/util/README.zh.md b/packages/util/README.zh.md index 5c66ded33a..59a2dcf792 100644 --- a/packages/util/README.zh.md +++ b/packages/util/README.zh.md @@ -10,6 +10,7 @@ | `paths/` | 规范的单根 `DSH_HOME` 解析,以及 harness 用户数据的共享文件系统路径常量和辅助工具(无 harness 依赖) | | `timeout/` | 超时的时序/分类部分:`clampTimeout`/`deadline`/`timeoutOf`/`TimeoutReason`(纯函数,无 harness 依赖);终止机制保留在各个功能中 | | `retention/` | 有界的面向模型输出:`ItemRetainer`/`TextRetainer` 加上中性通知辅助工具(纯工具,无 harness 依赖);业务语义保留在各个工具中 | +| `atomic-write/` | 原子文件替换:`writeFileAtomic`(独占创建临时文件 + 携带调用方所声明 mode 的 rename);由设置与凭据存储共用 | | `native-command/` | 宿主原生 OS 集成的免 shell `execFile` 运行器——utf8 捕获、abort 传播、Windows 窗口隐藏(无 harness 依赖);命令选择保留在各调用方 | `dsh-brand` 是规范示例:它只负责 `Branded` 辅助工具,因此功能包可以为自己拥有的 id 添加品牌(`dsh-tasks` 的 `TaskId`、`dsh-session` 的 `SessionId` 等),而只需依赖 `dsh-brand`,无需仅为使用 `Branded` 而引入不相关的包。 diff --git a/packages/util/atomic-write/README.i18n.yaml b/packages/util/atomic-write/README.i18n.yaml new file mode 100644 index 0000000000..ffa4d7ccbb --- /dev/null +++ b/packages/util/atomic-write/README.i18n.yaml @@ -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 packages/util/atomic-write/README.md +README.md: be9f896eb24e28aedc2c04858da8b8da9da548dc +README.zh.md: 19a067dc84f12d334e5c31dda58e7cf78dac51f9 diff --git a/packages/util/atomic-write/README.md b/packages/util/atomic-write/README.md new file mode 100644 index 0000000000..be9f896eb2 --- /dev/null +++ b/packages/util/atomic-write/README.md @@ -0,0 +1,45 @@ +# dsh-atomic-write + +English | [中文](README.zh.md) + +Zero-dependency atomic file replacement shared by file-backed stores that must never leave partial, symlink-hijacked, or wider-than-intended content on disk — the user-settings document (`dsh-settings-local`) and the credentials store (`dsh-credentials-local`). + +## Surface + +```ts +import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' + +declare const text: string +declare const render: (previous: string) => string + +await writeFileAtomic('/home/u/.dsh/settings.yaml', text, { mode: 0o600 }) + +// Read-modify-write against the same file from several processes. +await withFileLock('/home/u/.dsh/settings.yaml', async () => { + await writeFileAtomic('/home/u/.dsh/settings.yaml', render(text), { mode: 0o600 }) +}) +``` + +`writeFileAtomic` commits one already-rendered string. The contract, in the order failures would exploit it: + +- **Exclusive-create temp** (`wx`, random suffix): the open refuses to follow a symlink planted at a guessable temp path. +- **The fresh inode carries `mode` through the rename**: replacing a wider-permission file narrows it without a chmod race. `mode` is required so the permission decision stays visible at every call site (subject to the process umask, like every fresh inode). +- **`rename` replaces a symlinked target itself**, never writing through to its referent. +- **Same-directory sibling** keeps the rename on one filesystem, so the swap stays atomic. +- Parent directories are created; on any failure the temp is removed and the failure rethrown; readers observe either the old or the new complete content. + +`withFileLock` serializes the writers of one file across processes, for the read-render-commit cycles a bare atomic commit cannot make safe on its own. The lock is a `wx`-created `.lock` sibling, so readers never contend; waiters back off exponentially and fail with a timeout rather than block forever. A lock older than the stale age is treated as a crashed holder and broken — see [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) for what that costs. + +## Model Experience + +None, as this is a pure filesystem primitive; nothing here reaches a model request. + +#### KV Cache effect + +None; nothing here enters a request prefix. + +## Known Limitations and Deferred Work + +- **Atomic, not durable** — no `fsync` of the file or its directory, so after a crash the rename may be observed unwound. The file-backed stores here re-read and republish on boot, keeping durability the caller's policy. +- **String content only** — no `Buffer` or stream form until a consumer needs one. +- **The lock takes over by age, not by ownership** (`TODO(settings-lock-ownership)`) — a holder slower than the stale age has its lock broken by a waiter, and release unlinks the path unconditionally, so a slow writer can remove a successor's lock. Two writers can then overlap and one cycle's result be lost. The stale age is set well above any write this repo performs, so the exposure is a paused or swapped-out process; ownership-safe acquisition and release is the fix. diff --git a/packages/util/atomic-write/README.zh.md b/packages/util/atomic-write/README.zh.md new file mode 100644 index 0000000000..19a067dc84 --- /dev/null +++ b/packages/util/atomic-write/README.zh.md @@ -0,0 +1,45 @@ +# dsh-atomic-write + +[English](README.md) | 中文 + +零依赖的原子文件替换,供绝不允许在磁盘上留下不完整、被符号链接劫持或权限过宽内容的文件型存储共用:用户设置文档(`dsh-settings-local`)与凭据存储(`dsh-credentials-local`)。 + +## 接口面 + +```ts +import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' + +declare const text: string +declare const render: (previous: string) => string + +await writeFileAtomic('/home/u/.dsh/settings.yaml', text, { mode: 0o600 }) + +// Read-modify-write against the same file from several processes. +await withFileLock('/home/u/.dsh/settings.yaml', async () => { + await writeFileAtomic('/home/u/.dsh/settings.yaml', render(text), { mode: 0o600 }) +}) +``` + +`writeFileAtomic` 提交一份已经渲染好的字符串。契约按故障利用它的先后顺序列出: + +- **独占创建临时文件**(`wx` + 随机后缀):open 拒绝跟随预先埋在可猜测临时路径上的符号链接。 +- **全新 inode 携带 `mode` 走完 rename**:替换权限过宽的旧文件时直接收窄,不存在 chmod 竞态。`mode` 为必填,让权限决策始终可见于每个调用点(与所有新建 inode 一样受进程 umask 影响)。 +- **`rename` 替换的是符号链接目标本身**,绝不写穿到其指向的文件。 +- **同目录兄弟文件**保证 rename 落在同一文件系统上,交换保持原子。 +- 自动创建父目录;任何失败都会移除临时文件并重新抛出该失败;读取方只会观察到旧内容或完整的新内容。 + +`withFileLock` 跨进程串行化同一文件的写入方,服务于单靠原子提交无法保证安全的读-渲染-提交循环。锁是以 `wx` 创建的同目录 `.lock`,因此读取方从不参与竞争;等待方按指数退避,超时即失败而非无限阻塞。超过陈旧时限的锁被视为持有者已崩溃并被打破——其代价见[Known Limitations and Deferred Work](#known-limitations-and-deferred-work)。 + +## Model Experience + +无:本包是纯文件系统原语,此处没有任何内容会到达模型请求。 + +#### KV Cache effect + +无;此处没有任何内容会进入请求前缀。 + +## Known Limitations and Deferred Work + +- **原子但不保证持久**——不对文件或其所在目录做 `fsync`,因此崩溃后可能观察到 rename 被回退。此处的文件型存储在启动时重新读取并重新发布,把持久性留作调用方的策略。 +- **仅支持字符串内容**——在有消费方需要之前,不提供 `Buffer` 或流式形态。 +- **锁按时长而非归属接管**(`TODO(settings-lock-ownership)`)——持有者若慢于陈旧时限,其锁会被等待方打破,而释放又无条件删除该路径,因此慢写入方可能删掉后继者的锁。两个写入方随之重叠,一轮循环的结果可能丢失。陈旧时限远高于本仓库的任何一次写入,因此暴露面是被暂停或被换出的进程;修法是按归属安全地获取与释放。 diff --git a/packages/util/atomic-write/package.json b/packages/util/atomic-write/package.json new file mode 100644 index 0000000000..147ecb1e05 --- /dev/null +++ b/packages/util/atomic-write/package.json @@ -0,0 +1,37 @@ +{ + "name": "@deepseek-ai/dsh-atomic-write", + "description": "Zero-dependency atomic file replacement: exclusive-create random-suffix temp + rename carrying the caller-stated permissions (writeFileAtomic)", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/util/atomic-write/src/index.ts b/packages/util/atomic-write/src/index.ts new file mode 100644 index 0000000000..07d0276033 --- /dev/null +++ b/packages/util/atomic-write/src/index.ts @@ -0,0 +1,157 @@ +/** + * 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 `.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, stat, writeFile } from 'node:fs/promises' +import { dirname } from 'node:path' + +/** + * Filesystem options for {@link writeFileAtomic}; `mode` is required so the + * permission decision stays visible at every call site. + */ +export interface WriteFileAtomicOptions { + /** + * Permission bits stamped on the fresh temp inode and carried through the + * 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 +} + +/** + * Replace `filename` with `content` in one atomic step, creating parent + * directories. The content is first written to a random-suffix sibling opened + * with exclusive create (`wx`): the open refuses to follow a symlink planted + * at the temp path, and the fresh inode carries `options.mode` through the + * rename, so replacing a wider-permission file narrows it without a chmod + * race. The rename also replaces a symlinked target itself instead of writing + * through to its referent, and the same-directory sibling keeps the rename on + * one filesystem. On any failure the temp file is removed and the failure + * rethrown. Crash durability (fsync) is out of scope. + * @param filename - final path receiving the content. + * @param content - complete next file content. + * @param options - permission bits for the replacement inode. + */ +export async function writeFileAtomic(filename: string, content: string, options: WriteFileAtomicOptions): Promise { + await mkdir(dirname(filename), { + recursive: true, + ...options.dirMode === undefined ? {} : { mode: options.dirMode }, + }) + // TODO(settings-atomic-durability): Use a replacement that fsyncs the file + // and parent directory and preserves owner-only permissions on Windows. + const temp = `${filename}.${randomBytes(6).toString('hex')}.tmp` + try { + await writeFile(temp, content, { mode: options.mode, flag: 'wx' }) + await rename(temp, filename) + } catch (error) { + await rm(temp, { force: true }) + 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 { + 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 (`.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( + filename: string, + operation: () => Promise, + options?: WithFileLockOptions, +): Promise { + 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) { + // TODO(settings-lock-ownership): Replace age-only takeover with ownership-safe + // acquisition and release so a slow writer cannot remove a successor's lock. + 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 }) + } +} diff --git a/packages/util/atomic-write/src/invariant.ts b/packages/util/atomic-write/src/invariant.ts new file mode 100644 index 0000000000..4027dd9bda --- /dev/null +++ b/packages/util/atomic-write/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-atomic-write`. + * @module @deepseek-ai/dsh-atomic-write/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-atomic-write' + +/** Cordis companion plugin name. */ +export const name = 'atomic-write-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: this pure filesystem primitive owns no event stream or mutable runtime + * data; its replacement contract is enforced by unit tests. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/util/atomic-write/tests/atomic-write.spec.ts b/packages/util/atomic-write/tests/atomic-write.spec.ts new file mode 100644 index 0000000000..2bc9d3ab6a --- /dev/null +++ b/packages/util/atomic-write/tests/atomic-write.spec.ts @@ -0,0 +1,48 @@ +import { lstat, mkdir, mkdtemp, readFile, readdir, stat, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { writeFileAtomic } from '../src/index.ts' + +async function scratch(): Promise { + return mkdtemp(join(tmpdir(), 'dsh-atomic-write-')) +} + +describe('writeFileAtomic', () => { + it('creates the file and its parents with exactly the stated mode', async () => { + const dir = await scratch() + const target = join(dir, 'nested', 'deep', 'doc.yaml') + await writeFileAtomic(target, 'a: 1\n', { mode: 0o600 }) + expect(await readFile(target, 'utf8')).toBe('a: 1\n') + expect((await stat(target)).mode & 0o777).toBe(0o600) + }) + + it('replaces existing content and narrows a wider-permission file to the stated mode', async () => { + const dir = await scratch() + const target = join(dir, 'doc.yaml') + await writeFile(target, 'old', { mode: 0o644 }) + await writeFileAtomic(target, 'new', { mode: 0o600 }) + expect(await readFile(target, 'utf8')).toBe('new') + expect((await stat(target)).mode & 0o777).toBe(0o600) + }) + + it('replaces a symlinked target itself without writing through to the referent', async () => { + const dir = await scratch() + const victim = join(dir, 'victim') + await writeFile(victim, 'victim-content') + const target = join(dir, 'doc.yaml') + await symlink(victim, target) + await writeFileAtomic(target, 'replaced', { mode: 0o600 }) + expect((await lstat(target)).isSymbolicLink()).toBe(false) + expect(await readFile(target, 'utf8')).toBe('replaced') + expect(await readFile(victim, 'utf8')).toBe('victim-content') + }) + + it('leaves no temp sibling and rethrows when the rename fails', async () => { + const dir = await scratch() + const target = join(dir, 'occupied') + await mkdir(target) + await expect(writeFileAtomic(target, 'content', { mode: 0o600 })).rejects.toThrow() + expect((await readdir(dir)).filter(entry => entry.includes('.tmp'))).toEqual([]) + }) +}) diff --git a/packages/util/atomic-write/tests/invariant.spec.ts b/packages/util/atomic-write/tests/invariant.spec.ts new file mode 100644 index 0000000000..c80346762c --- /dev/null +++ b/packages/util/atomic-write/tests/invariant.spec.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as AtomicWriteInvariant from '../src/invariant.ts' + +describe('atomic-write invariant companion', () => { + it('registers its explained empty runtime invariant', async () => { + const ctx = new Context() + await ctx.plugin(InvariantService) + const fiber = await ctx.plugin(AtomicWriteInvariant) + + expect(() => { + ctx.invariants.register('@deepseek-ai/dsh-atomic-write', () => {}) + }).toThrow(/already registered/) + await fiber.dispose() + await ctx.fiber.dispose() + }) +}) diff --git a/packages/util/atomic-write/tsconfig.json b/packages/util/atomic-write/tsconfig.json new file mode 100644 index 0000000000..d970a00263 --- /dev/null +++ b/packages/util/atomic-write/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../support/invariants" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index adb73145e8..3e0ba77304 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -228,6 +228,9 @@ importers: '@deepseek-ai/dsh-compact-tool-result-prune': specifier: workspace:^ version: link:../../packages/compact/compact-tool-result-prune + '@deepseek-ai/dsh-credentials-local': + specifier: workspace:^ + version: link:../../packages/credentials/credentials-local '@deepseek-ai/dsh-frontend': specifier: workspace:^ version: link:../web @@ -321,6 +324,9 @@ importers: '@deepseek-ai/dsh-session-title-first-message-llm': specifier: workspace:^ version: link:../../packages/session-title/session-title-first-message-llm + '@deepseek-ai/dsh-settings-local': + specifier: workspace:^ + version: link:../../packages/settings/settings-local '@deepseek-ai/dsh-skill': specifier: workspace:^ version: link:../../packages/skill/skill @@ -542,6 +548,9 @@ importers: '@deepseek-ai/dsh-compact-tool-result-prune': specifier: workspace:* version: link:../packages/compact/compact-tool-result-prune + '@deepseek-ai/dsh-credentials-local': + specifier: workspace:* + version: link:../packages/credentials/credentials-local '@deepseek-ai/dsh-fs-local': specifier: workspace:* version: link:../packages/fs/fs-local @@ -638,6 +647,9 @@ importers: '@deepseek-ai/dsh-session-title-first-message-llm': specifier: workspace:* version: link:../packages/session-title/session-title-first-message-llm + '@deepseek-ai/dsh-settings-local': + specifier: workspace:* + version: link:../packages/settings/settings-local '@deepseek-ai/dsh-skill': specifier: workspace:* version: link:../packages/skill/skill @@ -2292,6 +2304,46 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/credentials/credentials: + devDependencies: + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + + packages/credentials/credentials-local: + dependencies: + chokidar: + specifier: ^4.0.3 + version: 4.0.3 + dotenv: + specifier: ^17.2.0 + version: 17.4.2 + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-atomic-write': + specifier: workspace:^ + version: link:../../util/atomic-write + '@deepseek-ai/dsh-credentials': + specifier: workspace:^ + version: link:../credentials + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-paths': + specifier: workspace:^ + version: link:../../util/paths + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/examples/acp-demo: devDependencies: '@cordisjs/plugin-include': @@ -3235,12 +3287,18 @@ importers: specifier: ^3.18.0 version: 3.18.0 devDependencies: + '@deepseek-ai/dsh-credentials': + specifier: workspace:^ + version: link:../../credentials/credentials '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../llm + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../settings/settings '@deepseek-ai/dsh-timeout': specifier: workspace:^ version: link:../../util/timeout @@ -3257,6 +3315,9 @@ importers: specifier: ^3.18.0 version: 3.18.0 devDependencies: + '@deepseek-ai/dsh-credentials': + specifier: workspace:^ + version: link:../../credentials/credentials '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -3266,6 +3327,9 @@ importers: '@deepseek-ai/dsh-llm-deepseek': specifier: workspace:^ version: link:../llm-deepseek + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../settings/settings '@deepseek-ai/dsh-timeout': specifier: workspace:^ version: link:../../util/timeout @@ -4270,6 +4334,9 @@ importers: specifier: ^2.9.0 version: 2.9.0 devDependencies: + '@deepseek-ai/dsh-atomic-write': + specifier: workspace:^ + version: link:../../util/atomic-write '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -5438,6 +5505,15 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/util/atomic-write: + devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/util/brand: devDependencies: '@deepseek-ai/dsh-invariants': @@ -5864,6 +5940,9 @@ importers: '@deepseek-ai/dsh-compact-tool-result-prune': specifier: workspace:^ version: link:../../packages/compact/compact-tool-result-prune + '@deepseek-ai/dsh-credentials': + specifier: workspace:^ + version: link:../../packages/credentials/credentials '@deepseek-ai/dsh-fs': specifier: workspace:^ version: link:../../packages/fs/fs @@ -5975,6 +6054,9 @@ importers: '@deepseek-ai/dsh-session-title': specifier: workspace:^ version: link:../../packages/session-title/session-title + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../packages/settings/settings '@deepseek-ai/dsh-skill': specifier: workspace:^ version: link:../../packages/skill/skill @@ -6095,6 +6177,9 @@ importers: cordis: specifier: workspace:^ version: link:../../vendor/cordis + schemastery: + specifier: workspace:^ + version: link:../../vendor/schemastery vendor/cordis: dependencies: @@ -9237,6 +9322,10 @@ packages: dompurify@3.4.11: resolution: {integrity: sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==} + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + engines: {node: '>=12'} + dts-resolver@3.0.0: resolution: {integrity: sha512-1T1f+z+4tl9XD+m+0HBgWoL/nm0bOIffyWaUuUSBlFg/86IWvfx+wjNaO/ybU0AJzG9/Mi5hBUgGV6zCmWEN7Q==} engines: {node: ^22.18.0 || >=24.0.0} @@ -14268,6 +14357,8 @@ snapshots: optionalDependencies: '@types/trusted-types': 2.0.7 + dotenv@17.4.2: {} + dts-resolver@3.0.0(oxc-resolver@11.20.0): optionalDependencies: oxc-resolver: 11.20.0 diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index cbec3a4923..5af9f4fc8c 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -10,8 +10,8 @@ "@cordisjs/plugin-timer": "workspace:^", "@deepseek-ai/dsh-acp": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", @@ -22,6 +22,8 @@ "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-compact": "workspace:^", "@deepseek-ai/dsh-compact-basic": "workspace:^", + "@deepseek-ai/dsh-compact-tool-result-prune": "workspace:^", + "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-fs-policy": "workspace:^", @@ -32,37 +34,34 @@ "@deepseek-ai/dsh-hooks-codex": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-jsonrpc": "workspace:^", - "@deepseek-ai/dsh-sdk-protocol": "workspace:^", "@deepseek-ai/dsh-jsonrpc-demo": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^", - "@deepseek-ai/dsh-token-meter": "workspace:^", - "@deepseek-ai/dsh-compact-tool-result-prune": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-llm-pi-ai": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", + "@deepseek-ai/dsh-paths": "workspace:^", + "@deepseek-ai/dsh-permission": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", "@deepseek-ai/dsh-pty": "workspace:^", "@deepseek-ai/dsh-pty-local": "workspace:^", - "@deepseek-ai/dsh-subprocess": "workspace:^", - "@deepseek-ai/dsh-subprocess-local": "workspace:^", - "@deepseek-ai/dsh-permission": "workspace:^", - "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-repeat-tool-guard": "workspace:^", "@deepseek-ai/dsh-retention": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-sdk-protocol": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-session-query": "workspace:^", "@deepseek-ai/dsh-session-query-sqlite": "workspace:^", "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-skill-local": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", @@ -70,19 +69,22 @@ "@deepseek-ai/dsh-subagent-fork": "workspace:^", "@deepseek-ai/dsh-subagent-inprocess": "workspace:^", "@deepseek-ai/dsh-subagent-spawn": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tasks": "workspace:^", "@deepseek-ai/dsh-tasks-local": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-timeout-policy": "workspace:^", + "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-tool-ask-user": "workspace:^", "@deepseek-ai/dsh-tool-bash": "workspace:^", - "@deepseek-ai/dsh-tool-cordis": "workspace:^", "@deepseek-ai/dsh-tool-bash-persistent": "workspace:^", + "@deepseek-ai/dsh-tool-cordis": "workspace:^", "@deepseek-ai/dsh-tool-fs": "workspace:^", - "@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^", "@deepseek-ai/dsh-tool-goal": "workspace:^", "@deepseek-ai/dsh-tool-skill": "workspace:^", + "@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^", "@deepseek-ai/dsh-tool-subagent": "workspace:^", "@deepseek-ai/dsh-tool-tasks": "workspace:^", "@deepseek-ai/dsh-tool-todo": "workspace:^", @@ -96,9 +98,10 @@ "@deepseek-ai/dsh-web-search-deepseek": "workspace:^", "@deepseek-ai/dsh-web-search-exa": "workspace:^", "@deepseek-ai/dsh-web-search-perplexity": "workspace:^", - "@deepseek-ai/dsh-workspace-context": "workspace:^", "@deepseek-ai/dsh-workflow": "workspace:^", "@deepseek-ai/dsh-workflow-workerthread": "workspace:^", - "cordis": "workspace:^" + "@deepseek-ai/dsh-workspace-context": "workspace:^", + "cordis": "workspace:^", + "schemastery": "workspace:^" } } diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index 537abeff00..16fa0265d6 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -1,5 +1,5 @@ { - "AGENTS.md": 1765, + "AGENTS.md": 1775, "docs/AGENTS.md": 1150, "docs/architecture.md": 1920, "docs/cordis-primer.md": 600, @@ -7,5 +7,5 @@ "docs/testing.md": 1100, "examples/AGENTS.md": 310, "packages/AGENTS.md": 675, - "packages/README.md": 905 + "packages/README.md": 920 } diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 67cd6c84f8..b9dbb501ec 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -33,6 +33,7 @@ export const LINK_MAP: Readonly> = { MessageId: 'core.md', HookContext: 'core.md', SettleReason: 'core.md', + AdapterRegistrationHandle: 'core.md', LlmCallConfig: 'core.md', LlmModelContext: 'core.md', LlmModelReasoningInfo: 'core.md', @@ -191,6 +192,9 @@ export const LINK_MAP: Readonly> = { SettingsScope: 'settings.md', SettingsDescriptor: 'settings.md', SettingsUpdateSource: 'settings.md', + CredentialRef: 'credentials.md', + CredentialInfo: 'credentials.md', + ResolvedCredential: 'credentials.md', AskUserQuestionAnswer: 'user-interaction.md', AskUserQuestionRequest: 'user-interaction.md', UserInteractionProvider: 'user-interaction.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 826e326436..3bc93ba88b 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -158,8 +158,17 @@ const SERVICE_ROLES: ServiceRole[] = [ title: 'User-settings seam', mode: 'seam', implementations: ['settings-local'], - consumers: [], - note: 'Plugins register namespace schemas and resolve layered values; providers store the raw document. No production consumer is migrated yet.', + consumers: ['llm-deepseek', 'llm-pi-ai'], + note: 'Plugins register namespace schemas and resolve layered values; providers store the raw document. The LLM adapters register their entry config as the composition base under the user section.', + }, + { + key: 'credentials', + pkg: 'credentials', + title: 'Credential seam', + mode: 'seam', + implementations: ['credentials-local'], + consumers: ['llm-deepseek', 'llm-pi-ai'], + note: 'Configuration carries references to secrets; providers own the values. Consumers resolve per operation, so a rotated credential reaches the very next request.', }, { key: 'telemetry', diff --git a/scripts/project-doc-site.spec.ts b/scripts/project-doc-site.spec.ts index 28f854fe9b..3892a0e5f3 100644 --- a/scripts/project-doc-site.spec.ts +++ b/scripts/project-doc-site.spec.ts @@ -197,7 +197,7 @@ describe('docsPages locale routes', () => { const translated = rootPages.filter(page => page.contentLocale === 'zh-CN') const fallbacks = rootPages.filter(page => page.contentLocale === 'en-US') - expect(translated).toHaveLength(19) + expect(translated).toHaveLength(20) expect(translated.every(page => page.source.endsWith('.zh.md'))).toBe(true) expect(fallbacks.map(page => page.source).sort()).toEqual([ 'docs/core-data-structures/commands.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 61f1ce96db..3b84c91e9c 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -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", @@ -1368,6 +1373,21 @@ "doc": "docs/core-data-structures/settings.md", "symbol": "SettingsUpdateSource", "source": "packages/settings/settings/src/index.ts" + }, + { + "doc": "docs/core-data-structures/credentials.md", + "symbol": "CredentialRef", + "source": "packages/credentials/credentials/src/index.ts" + }, + { + "doc": "docs/core-data-structures/credentials.md", + "symbol": "ResolvedCredential", + "source": "packages/credentials/credentials/src/index.ts" + }, + { + "doc": "docs/core-data-structures/credentials.md", + "symbol": "CredentialInfo", + "source": "packages/credentials/credentials/src/index.ts" } ] } diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 615cc2832f..f5cab677be 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -104,6 +104,9 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/session-query/session-query-sqlite': { kind: 'none', reason: 'The search backend returns hits only to callers and registers no model surface.' }, 'packages/settings/settings': { kind: 'indirect', reason: 'The seam stores and resolves user settings; consumer plugins own any model surface a value feeds.' }, 'packages/settings/settings-local': { kind: 'indirect', reason: 'The file provider stores and publishes namespace sections; consumers of ctx.settings own any model surface.' }, + 'packages/credentials/credentials': { kind: 'indirect', reason: 'The seam resolves credential references; the consuming adapter owns every model surface a value authorizes.' }, + 'packages/credentials/credentials-local': { kind: 'indirect', reason: 'The file/environment provider stores credential values; consumers of ctx.credentials own any model surface.' }, + 'packages/util/atomic-write': { kind: 'none', reason: 'Pure filesystem write primitive; registers no model surface.' }, 'packages/telemetry/session-telemetry': { kind: 'none', reason: 'The seam observes the session stream and hands redacted copies outward; it registers no model surface.' }, 'packages/telemetry/session-telemetry-otel': { kind: 'none', reason: 'The backend forwards seam records into the OTel SDK pipeline and registers no model surface.' }, 'packages/skill/skill': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-skill.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 5a69af958d..f7fda1e7c9 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -95,6 +95,7 @@ "./packages/session-title/*/src/invariant.ts", "./packages/session-query/*/src/invariant.ts", "./packages/settings/*/src/invariant.ts", + "./packages/credentials/*/src/invariant.ts", "./packages/telemetry/*/src/invariant.ts", "./packages/acp/*/src/invariant.ts", "./packages/storage/*/src/invariant.ts", @@ -189,6 +190,7 @@ "./packages/session-query/*/src", "./packages/session-title/*/src", "./packages/settings/*/src", + "./packages/credentials/*/src", "./packages/telemetry/*/src", "./packages/acp/*/src", "./packages/storage/*/src", diff --git a/tsconfig.host.json b/tsconfig.host.json index d5c6b9a03a..d255271dfa 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -61,6 +61,7 @@ { "path": "./packages/util/paths" }, { "path": "./packages/util/timeout" }, { "path": "./packages/util/retention" }, + { "path": "./packages/util/atomic-write" }, { "path": "./packages/llm/llm" }, { "path": "./packages/llm/token-meter" }, { "path": "./packages/core/session" }, @@ -77,6 +78,8 @@ { "path": "./packages/session-query/session-query-sqlite" }, { "path": "./packages/settings/settings" }, { "path": "./packages/settings/settings-local" }, + { "path": "./packages/credentials/credentials" }, + { "path": "./packages/credentials/credentials-local" }, { "path": "./packages/session-query/tool-session-query" }, { "path": "./packages/storage/storage" }, { "path": "./packages/storage/storage-json" }, diff --git a/website/docs.ts b/website/docs.ts index cbc8d9e627..a640d01e18 100644 --- a/website/docs.ts +++ b/website/docs.ts @@ -257,6 +257,7 @@ const coreDataReference = pairedPages(([ ['web.md', 'Web 访问', 'Web access', 19], ['persistence.md', '会话持久化', 'Session persistence', 20], ['settings.md', '用户设置', 'User settings', 21], + ['credentials.md', '用户凭据', 'User credentials', 22], ] as const).map(([file, rootLabel, enLabel, order]): PairedPage => ({ source: `docs/core-data-structures/${file}`, route: `reference/core-data-structures/${file}`,