From 88c035c98e2992641d390bd083be400da5d7d3c2 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 4 Aug 2026 14:11:38 +0800 Subject: [PATCH 01/27] cleanup(cli): remove the profile-json config entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `./.dsh-tmp-profile/config.json` was the web config-tree boot's user-config plane, but never gained a writer: no production code created or edited it, no test exercised it, and no user documentation named it. The fields it mapped have owners elsewhere — provider/model are the api-gateway's default route and persistenceRoot is an assembly fact, while typed user preferences live in $DSH_HOME/settings.yaml. Delete PROFILE_DIR, PROFILE_FILE, ProfileMapping, PROFILE_MAPPINGS, and readProfile() with the patch source that consumed them. AppCLIEntry now composes patches from CLI flags and the resolved frontend distIndex only; the surrounding layers are unchanged. A file on disk is ignored completely — no migration, replacement format, or deprecation diagnostic, per the pre-release stance. --- ...tree-boot-and-transport-layering.i18n.yaml | 4 +- ...config-tree-boot-and-transport-layering.md | 4 +- ...fig-tree-boot-and-transport-layering.zh.md | 4 +- ...-08-04-remove-profile-json-entry.i18n.yaml | 6 ++ .../2026-08-04-remove-profile-json-entry.md | 32 +++++++++ ...2026-08-04-remove-profile-json-entry.zh.md | 32 +++++++++ apps/cli/config/web.cordis.yml | 6 +- apps/cli/src/app-cli-entry.ts | 70 +++---------------- docs/user/guide/config.i18n.yaml | 4 +- docs/user/guide/config.md | 2 +- docs/user/guide/config.zh.md | 2 +- 11 files changed, 94 insertions(+), 72 deletions(-) create mode 100644 .agents/notes/implemented/simplification/2026-08-04-remove-profile-json-entry.i18n.yaml create mode 100644 .agents/notes/implemented/simplification/2026-08-04-remove-profile-json-entry.md create mode 100644 .agents/notes/implemented/simplification/2026-08-04-remove-profile-json-entry.zh.md diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml index d50428d5ed..aede84e27f 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.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 .agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md -2026-07-24-web-config-tree-boot-and-transport-layering.md: 88f94b1f58ae7a3451c7772f4a9ff7d6564254c0 -2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: ea2a8f70a6c2d4207d4388a9303fbc6ce6e94238 +2026-07-24-web-config-tree-boot-and-transport-layering.md: e4dd8b50fe565deecb6e64d307305c66af50c001 +2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 54b0a0e499954cd0e2ccd22cffdf7d09bed11a22 diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md index 88f94b1f58..e4dd8b50fe 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md @@ -16,7 +16,7 @@ English | [中文](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md) **Boot glue is a class pair.** `AppCLIEntry` (apps/cli) and `AppWebEntry` (the shell kernel) hold only what must exist independently of cordis: argv facts, the composed patch set, the parsed boot manifest, the module system instance, loading-page handles — everything else lives in plugins. `AppCLIEntry.run()` is three stages: layered env (ambient > cwd `.env` > `$DSH_HOME/.env`, closing the defect above) → patch composition → Loader include boot plus the activation audit. `AppWebEntry.run()` mirrors it browser-side: parse `window.__DSH_BOOT__` into a `BootManifest` (two views: npm-package rows for the module table, cordis-plugin rows for entry composition; malformed wire throws), build the module system, render the loading page, prefetch the `immediately` tier in parallel with Context/Loader setup, **await the prefetch before creating entries** (materialization is `tree.import`'s synchronous require, unprotected by fiber inject waiting; cross-package require edges such as i18n → runtime/client need every immediately-tier factory registered first — an empirically found 10–25% boot race otherwise), adopt the modules entry, create the graph rows, settle, sweep. -**Config sources have one declaration place each.** yml static values are engineering defaults; the profile json (`./.dsh-tmp-profile/config.json`, read-only, never created, cwd-anchored until the `$DSH_HOME` migration) is user config mapped through a static `PROFILE_MAPPINGS` table onto target rows (`provider`/`model` → the `api-gateway` row, `persistenceRoot` → the jsonl row); CLI flags map onto the `webserver` row with a field set disjoint from the json's; env values enter through yml `!!js` expressions, never through the mapping table. Patches replace a row's config wholesale, so the entry class re-reads the yml row's static values (bypass parse) and merges overrides on top. An unmapped json key fails loud. The resolved frontend `distIndex` rides the same patch channel — an assembly fact, not user config. +**Config sources have one declaration place each.** yml static values are engineering defaults; CLI flags map onto the `webserver` row; env values enter through yml `!!js` expressions. This decision also introduced a profile json (`./.dsh-tmp-profile/config.json`) as the user-config source, mapped through a static `PROFILE_MAPPINGS` table onto target rows; it never gained a writer and is [now removed](../simplification/2026-08-04-remove-profile-json-entry.md), leaving flags and the assembly fact below as the only patch sources. Patches replace a row's config wholesale, so the entry class re-reads the yml row's static values (bypass parse) and merges overrides on top. The resolved frontend `distIndex` rides the same patch channel — an assembly fact, not user config. **The transport splits five ways.** `dsh-host-apiproxy` upgraded to the gateway plugin (`api-gateway` row): default-exports `ApiProxyService`, config `{provider, model}`, provides `ctx.apiProxy`, transport-agnostic and registers no routes — `createApiProxy` moved here from the retired runtime package. `dsh-host-webserver` shrank to a plain route-registration plugin: `HttpServerService` provides `ctx.httpServer` (`register(route) → disposer` with duplicate-pattern throw, `tapIndex` transforms applied in registration order, `port`), listens on activation, per-request failures answer 400 and log without exiting, and knows no harness concepts. The connection node half owns the binding: it injects both services and registers `toFetchHandler(ctx.apiProxy)` under the `/api` prefix — future IPC carriers swap connection's transport while the gateway stays untouched. The modules node half (`ClientModuleHostService`, providing `ctx.clientModuleHost`) owns the graph: incremental per-package scanning (no full-rescan code path — `internal/plugin` marks the fiber's entry name dirty, a flush reconciles each name against live entries, package metadata including negative verdicts is cached forever, re-hashing is reachable only through `rebuilt(id)`), the bundle route, the index tap, and `onRebuilt`/`onGraphChanged` notification. The hmr node half owns dev reload: `fs.watchFile` stat-polling driven by `onGraphChanged` membership, and the `/plugins/events` SSE route. @@ -25,7 +25,7 @@ English | [中文](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md) ## Consequences - Recomposing a web deployment is a yml/patch edit; the retired pieces (`mountWebPlugins`, `CLIENT_PACKAGES`, `createHostWebPluginRegistry`, `startWebServer`, the webserver's graph/SSE/api knowledge) are deleted. -- Headless boots the same composition through the same entry (landed in the stacked follow-up): port 0 is its only surface difference, the model face gains `ask_user_question`/workspace context/model titles per the unification ruling, and `bootHost`/`startHost` retired with the `dsh-host-runtime` package. The profile write path, the `$DSH_HOME` profile relocation, and IPC carriers remain recorded deferrals. +- Headless boots the same composition through the same entry (landed in the stacked follow-up): port 0 is its only surface difference, the model face gains `ask_user_question`/workspace context/model titles per the unification ruling, and `bootHost`/`startHost` retired with the `dsh-host-runtime` package. IPC carriers remain a recorded deferral; the profile write path and the `$DSH_HOME` profile relocation were dropped with the profile json itself. - A TypeScript pitfall worth remembering: a `declare module 'cordis'` augmentation in a file with **no cordis import** is demoted to a standalone module declaration and silently shatters the program-wide `Context` merge (`ctx.on`/`ctx.effect` vanish across the program). Anchor with `import type {} from 'cordis'`. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md index ea2a8f70a6..54b0a0e499 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md @@ -16,7 +16,7 @@ Status: implemented **boot 胶水是一对 class。** `AppCLIEntry`(apps/cli)与 `AppWebEntry`(壳内核)只持有独立于 cordis 必须提前存在的东西:argv 事实、合成的 patch 集、解析出的 boot manifest、模块系统实例、loading 页句柄——其余一律进插件。`AppCLIEntry.run()` 三段:分层 env(ambient > cwd `.env` > `$DSH_HOME/.env`,顺手关掉上述缺陷)→ patch 合成 → Loader include boot 加 activation audit。`AppWebEntry.run()` 在浏览器侧镜像它:把 `window.__DSH_BOOT__` 解析成 `BootManifest`(双视角:npm 包行给模块表、cordis 插件行给 entry 组合;畸形 wire 大声抛)、建模块系统、渲染 loading 页、immediately 层预取与 Context/Loader 准备并行、**create entry 之前等预取齐**(物化是 `tree.import` 的同步 require,不受 fiber inject 等待保护;i18n → runtime/client 这类跨包 require 边要求 immediately 层工厂全部注册完——否则有实测 10–25% 的 boot 竞态)、收编 modules entry、逐图行 create、settle、sweep。 -**每个配置源有唯一声明位置。** yml 静态值是工程默认;profile json(`./.dsh-tmp-profile/config.json`,只读、绝不创建、暂锚 cwd 直至 `$DSH_HOME` 迁移)是用户配置,经静态 `PROFILE_MAPPINGS` 表映射到目标行(`provider`/`model` → `api-gateway` 行,`persistenceRoot` → jsonl 行);CLI flags 映射到 `webserver` 行、字段集与 json 不相交;env 值经 yml `!!js` 表达式进入,绝不进映射表。patch 整体替换行 config,故 entry 类旁路 parse 重读 yml 行静态值再叠加覆盖。未映射的 json 键 fail loud。解析出的前端 `distIndex` 走同一 patch 通道——装配事实,不是用户配置。 +**每个配置源有唯一声明位置。** yml 静态值是工程默认;CLI flags 映射到 `webserver` 行;env 值经 yml `!!js` 表达式进入。本决策当时还引入了 profile json(`./.dsh-tmp-profile/config.json`)作为用户配置源,经静态 `PROFILE_MAPPINGS` 表映射到目标行;它始终没有获得写入方,[现已删除](../simplification/2026-08-04-remove-profile-json-entry.md),patch 来源只剩 flags 与下述装配事实。patch 整体替换行 config,故 entry 类旁路 parse 重读 yml 行静态值再叠加覆盖。解析出的前端 `distIndex` 走同一 patch 通道——装配事实,不是用户配置。 **传输五分。** `dsh-host-apiproxy` 升格网关插件(`api-gateway` 行):默认导出 `ApiProxyService`,config `{provider, model}`,provide `ctx.apiProxy`,传输无关、不注册路由——`createApiProxy` 自已退役的 runtime 包迁入。`dsh-host-webserver` 缩成朴素路由注册插件:`HttpServerService` provide `ctx.httpServer`(`register(route) → disposer`、重复 pattern 即抛、`tapIndex` 按注册序应用、`port`),激活即 listen,单请求失败答 400 并记日志不退进程,不认识任何 harness 概念。connection node 半拥有绑定:inject 两个服务,把 `toFetchHandler(ctx.apiProxy)` 注册在 `/api` 前缀下——将来 IPC 载体只换 connection 的传输,网关零改动。modules node 半(`ClientModuleHostService`,provide `ctx.clientModuleHost`)拥有图:单包增量扫描(无全量重扫路径——`internal/plugin` 把 fiber 的 entry 名标脏,flush 逐名对账 live entries,包元数据含否定结论永久缓存,重哈希唯一入口 `rebuilt(id)`)、bundle 路由、index tap、`onRebuilt`/`onGraphChanged` 通知。hmr node 半拥有开发期重载:`fs.watchFile` stat 轮询、watch 集合跟随 `onGraphChanged`、`/plugins/events` SSE 路由。 @@ -25,7 +25,7 @@ Status: implemented ## 后果 - 重组一个 web 部署 = 改 yml/patch;退役件(`mountWebPlugins`、`CLIENT_PACKAGES`、`createHostWebPluginRegistry`、`startWebServer`、webserver 的图/SSE/api 知识)全部删除。 -- headless 已在 stacked 后续轮迁入同一组合同一入口:唯一面差异是 port 0,模型面按统一裁决获得 `ask_user_question`/workspace context/模型标题,`bootHost`/`startHost` 随 `dsh-host-runtime` 包退役。profile 写入路径、profile 迁 `$DSH_HOME`、IPC 载体仍为挂账项。 +- headless 已在 stacked 后续轮迁入同一组合同一入口:唯一面差异是 port 0,模型面按统一裁决获得 `ask_user_question`/workspace context/模型标题,`bootHost`/`startHost` 随 `dsh-host-runtime` 包退役。IPC 载体仍为挂账项;profile 写入路径与 profile 迁 `$DSH_HOME` 已随 profile json 本身一并放弃。 - 一个值得记住的 TypeScript 坑:`declare module 'cordis'` augmentation 所在文件若**没有任何 cordis import**,会被降级成独立 module declaration,无声打散全程序的 `Context` merge(`ctx.on`/`ctx.effect` 全程序消失)。用 `import type {} from 'cordis'` 锚定。 ## Alternatives considered diff --git a/.agents/notes/implemented/simplification/2026-08-04-remove-profile-json-entry.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-04-remove-profile-json-entry.i18n.yaml new file mode 100644 index 0000000000..60bfb506ae --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-04-remove-profile-json-entry.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/simplification/2026-08-04-remove-profile-json-entry.md +2026-08-04-remove-profile-json-entry.md: 8ca81e2364e095d90c87febfe705ddec14269bf4 +2026-08-04-remove-profile-json-entry.zh.md: bbc3957d11a2051e7c1f9eaaed52d8af38fa1e5b diff --git a/.agents/notes/implemented/simplification/2026-08-04-remove-profile-json-entry.md b/.agents/notes/implemented/simplification/2026-08-04-remove-profile-json-entry.md new file mode 100644 index 0000000000..8ca81e2364 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-04-remove-profile-json-entry.md @@ -0,0 +1,32 @@ +# Agent Note: Removing the profile-json config entry + +Status: implemented + +English | [中文](2026-08-04-remove-profile-json-entry.zh.md) + +## Problem + +`./.dsh-tmp-profile/config.json` was the user-configuration plane of the [web config-tree boot](../architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md): a read-only JSON object under the invoking directory, mapped by a static `PROFILE_MAPPINGS` table onto three fields across two rows. Its write path and its relocation to the Harness home were recorded there as deferrals, and neither arrived. Nothing in the product ever created or edited the file, no test exercised it, and no user documentation named it — the format existed only as a reader. + +Meanwhile the fields it mapped acquired owners elsewhere. `provider` and `model` are the api-gateway's default route for created and resumed agents, which a session's own picker overrides per agent; `persistenceRoot` is an assembly fact of the shipped composition. Typed user preferences became `$DSH_HOME/settings.yaml` under the [user-settings seam](../architecture/2026-07-28-user-settings-seam.md). What remained was a third user-configuration format, anchored to the invoking directory and behind a hand-maintained mapping table, that nothing wrote. + +## Decision + +`PROFILE_DIR`, `PROFILE_FILE`, `ProfileMapping`, `PROFILE_MAPPINGS`, and `readProfile()` are deleted along with the patch source that consumed them. `AppCLIEntry` composes its patches from CLI flags and the resolved frontend `distIndex` only; the layers around it — shipped base, surface overlay, `--config` or the personal overlay, and `--config-replace` — are unchanged. + +A `.dsh-tmp-profile/config.json` on disk is now ignored completely. There is no migration, no replacement format, and no deprecation diagnostic: the file never had a producer, so there is no installed base to carry forward, and the [pre-release stance](../../../../AGENTS.md) rejects compatibility shims. + +## Alternatives considered + +**Keep the reader until typed settings own `provider`/`model`.** Rejected because the gap is not real: with no writer, the file gave users no way to pin a default route either, so keeping it preserves an unproduced format rather than a capability. + +**Relocate it to `$DSH_HOME`, the deferral the original note recorded.** Rejected because that deferral assumed the write path would arrive with it. Moving a file nothing writes only moves the dead entry, and the Harness home already has an owner for typed user preferences. + +**Report the file through a deprecation diagnostic when it exists.** Rejected because a diagnostic for a format the product never produced would advertise it to users who have never seen it. + +## Consequences + +- Given up: no file-based way to pin `provider`, `model`, or `persistenceRoot` without editing yml or passing `--config`. A persistent default route needs a typed settings namespace owned by whoever creates sessions; `persistenceRoot` stays an assembly fact. +- Bought: one fewer user-configuration format, one less input anchored to the invoking directory, and a patch composition whose only remaining sources are CLI flags and an assembly fact — the fail-loud mapping table goes with it. +- The [web config-tree boot note](../architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md) is only partially superseded: its composition, boot-glue, transport, and export decisions stand. Both notes stay cross-linked, and its profile facts were rewritten in place. +- Absence is verified by repo-wide search: `.dsh-tmp-profile`, `PROFILE_MAPPINGS`, and `readProfile` have no remaining match. diff --git a/.agents/notes/implemented/simplification/2026-08-04-remove-profile-json-entry.zh.md b/.agents/notes/implemented/simplification/2026-08-04-remove-profile-json-entry.zh.md new file mode 100644 index 0000000000..bbc3957d11 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-04-remove-profile-json-entry.zh.md @@ -0,0 +1,32 @@ +# Agent Note: 删除 profile-json 配置入口 + +Status: implemented + +[English](2026-08-04-remove-profile-json-entry.md) | 中文 + +## Problem + +`./.dsh-tmp-profile/config.json` 曾是 [web 配置树启动](../architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md)的用户配置面:调用目录下的一个只读 JSON 对象,经静态 `PROFILE_MAPPINGS` 表映射到两个行上的三个字段。它的写路径以及迁往 Harness home 的计划都记在那条 Note 里作为延后项,两者都没有落地。产品中从未有任何代码创建或编辑该文件,没有测试覆盖它,也没有用户文档提到它——这个格式只存在读取方。 + +与此同时,它映射的字段各自有了别处的归属。`provider` 与 `model` 是 api-gateway 为新建和恢复的 agent 提供的默认路由,会话自己的选择器可按 agent 覆盖它;`persistenceRoot` 是交付组合的装配事实。类型化的用户偏好则由 [user-settings seam](../architecture/2026-07-28-user-settings-seam.md) 下的 `$DSH_HOME/settings.yaml` 承接。剩下的只是第三个用户配置格式:锚定在调用目录、藏在一张手工维护的映射表后面,而且没有任何东西写它。 + +## Decision + +`PROFILE_DIR`、`PROFILE_FILE`、`ProfileMapping`、`PROFILE_MAPPINGS` 和 `readProfile()` 连同消费它们的那个 patch 来源一并删除。`AppCLIEntry` 现在只从 CLI 标志和解析出的前端 `distIndex` 合成 patch;它周围的各层——交付基座、surface overlay、`--config` 或个人 overlay、以及 `--config-replace`——保持不变。 + +磁盘上的 `.dsh-tmp-profile/config.json` 现在被完全忽略。没有迁移、没有替代格式、也没有弃用诊断:该文件从来没有生产方,因此不存在需要承接的存量,而[未发布阶段的立场](../../../../AGENTS.md)拒绝兼容垫片。 + +## Alternatives considered + +**保留读取方,直到类型化 settings 接管 `provider`/`model`。** 否决,因为这个缺口并不真实存在:既然没有写入方,该文件同样没有给用户任何钉住默认路由的途径,保留它保住的是一个无人生产的格式,而不是一项能力。 + +**按原 Note 记录的延后项,把它迁到 `$DSH_HOME`。** 否决,因为那条延后项的前提是写路径会随之到来。搬动一个没人写的文件只是搬动了这个死入口,而 Harness home 已经有了类型化用户偏好的归属者。 + +**文件存在时通过弃用诊断报告它。** 否决,因为为一个产品从未生产过的格式给出诊断,等于向从没见过它的用户宣传它。 + +## Consequences + +- 放弃的:不再有基于文件、无需编辑 yml 或传 `--config` 就能钉住 `provider`、`model` 或 `persistenceRoot` 的途径。持久的默认路由需要一个由会话创建方拥有的类型化 settings namespace;`persistenceRoot` 仍是装配事实。 +- 换来的:少一个用户配置格式,少一个锚定在调用目录的输入,以及一处仅剩 CLI 标志与装配事实两个来源的 patch 合成——那张 fail-loud 映射表随之消失。 +- [web 配置树启动 Note](../architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md) 只被部分取代:它关于组合、启动胶水、传输与导出的决策仍然成立。两条 Note 保持互链,其中与 profile 相关的事实已就地改写。 +- 缺席由全仓搜索验证:`.dsh-tmp-profile`、`PROFILE_MAPPINGS` 与 `readProfile` 均无残留匹配。 diff --git a/apps/cli/config/web.cordis.yml b/apps/cli/config/web.cordis.yml index daf597916e..efd2f93b2a 100644 --- a/apps/cli/config/web.cordis.yml +++ b/apps/cli/config/web.cordis.yml @@ -1,6 +1,6 @@ # `dsh web` — the browser surface, as a patch list over `base.cordis.yml`. # The launcher includes the base and applies this file, then any `--config` -# overlay, then AppCLIEntry's profile-json and CLI-flag patches, as sibling patch +# overlay, then AppCLIEntry's CLI-flag patches, as sibling patch # lists at ONE include level: patches never cross an include boundary, so # stacking overlays as nested includes would silently stop reaching base rows. # @@ -81,8 +81,8 @@ name: '@deepseek-ai/dsh-host-directory-picker-auto' # The API gateway: the transport-agnostic dispatch face every client shape - # shares. provider/model are the host default routing — the profile json's - # mapping target (user config overrides these engineering defaults). + # shares. provider/model are the host default route for created and resumed + # agents; a session's own picker overrides it per agent. - id: api-gateway name: '@deepseek-ai/dsh-host-apiproxy' config: diff --git a/apps/cli/src/app-cli-entry.ts b/apps/cli/src/app-cli-entry.ts index eaa1902eff..e46c8d653a 100644 --- a/apps/cli/src/app-cli-entry.ts +++ b/apps/cli/src/app-cli-entry.ts @@ -2,8 +2,8 @@ * AppCLIEntry — the pre-cordis boot glue the config-tree dsh surfaces share * (`dsh web` and `dsh -p`; the TUI composes dsh-app-boot directly). * 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 activation audit after the tree + * composition over the shipped base and surface overlay (CLI flags + the + * resolved frontend dist), and the fail-loud activation audit 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. @@ -12,7 +12,7 @@ import { readFileSync } from 'node:fs' import { createRequire } from 'node:module' import { networkInterfaces } from 'node:os' -import { join, resolve } from 'node:path' +import { resolve } from 'node:path' import { Context } from 'cordis' import type { PatchOptions } from '@cordisjs/plugin-include' import yaml from 'js-yaml' @@ -26,10 +26,6 @@ import { // Empty type import carries the httpServer Context merge for the port read below. import type {} from '@deepseek-ai/dsh-host-webserver' -/** Profile file under the invoking directory (read-only this round; never created — see the design's profile ruling). */ -const PROFILE_DIR = '.dsh-tmp-profile' -const PROFILE_FILE = 'config.json' - /** The session-telemetry row id the DSH_TELEMETRY_DISABLED switch targets (mounted in web.cordis.yml). */ const TELEMETRY_ROW_ID = 'telemetry-otel' @@ -100,25 +96,6 @@ export function configHasTelemetryRow(file: string): boolean { row.id === TELEMETRY_ROW_ID || (row.insert ?? []).some(inserted => inserted.id === TELEMETRY_ROW_ID)) } -/** One profile-json key mapped onto a yml row's config field. */ -interface ProfileMapping { - jsonPath: string - entryId: string - configKey: string -} - -/** - * The static profile→row mapping table. json is user config and wins over the - * yml engineering default per field; a json key absent from this table fails - * loud (a typo silently ignored would read as "setting has no effect"). - * Developers extend deployments by adding rows here. - */ -const PROFILE_MAPPINGS: ProfileMapping[] = [ - { jsonPath: 'provider', entryId: 'api-gateway', configKey: 'provider' }, - { jsonPath: 'model', entryId: 'api-gateway', configKey: 'model' }, - { jsonPath: 'persistenceRoot', entryId: 'session-persistence-jsonl', configKey: 'root' }, -] - // The include's YAML dialect: `!!js` scalars become expression nodes the // Loader evaluates at entry activation. The bypass parse below must accept // them (and passing one through a patch unchanged is legal). @@ -135,14 +112,14 @@ export interface AppCLIEntryOptions { configPath: string /** * Absolute path of this surface's overlay: a patch list applied over - * {@link configPath} before this entry's own profile/flag patches. Its rows + * {@link configPath} before this entry's own flag patches. Its rows * are also merge inputs, so a flag override preserves the overlay's other * fields on the same row. */ overlayPath: string /** * Optional explicit overlay applied after {@link overlayPath} and before - * this entry's own profile/flag patches. When absent, the personal + * this entry's own flag patches. When absent, the personal * `$DSH_HOME/config.yaml` overlay is applied instead. */ extraOverlayPath?: string @@ -205,8 +182,8 @@ export class AppCLIEntry { } /** - * 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 + * Compose the patch set from CLI flags and the resolved frontend dist. + * Patches replace a row's config wholesale, so each patched row's yml * static values are re-read here (bypass parse) and merged under the overrides. */ private composePatches(): void { @@ -218,28 +195,19 @@ export class AppCLIEntry { overrides.set(entryId, bag) } - // Source 1: profile json (missing file = empty; unmapped key = loud). - for (const [key, value] of Object.entries(this.readProfile())) { - const mapping = PROFILE_MAPPINGS.find(m => m.jsonPath === key) - if (mapping === undefined) { - throw new Error(`dsh: profile key "${key}" has no mapping (known: ${PROFILE_MAPPINGS.map(m => m.jsonPath).join(', ')})`) - } - put(mapping.entryId, mapping.configKey, value) - } - - // Source 2: CLI flags (field set disjoint from the json mappings). + // Source 1: CLI flags. if (this.options.host !== undefined) put('webserver', 'host', this.options.host) if (this.options.port !== undefined) put('webserver', 'port', this.options.port) if (this.options.workspaceRoot !== undefined) put('api-gateway', 'workspaceRoot', this.options.workspaceRoot) - // Source 2b: authorities for the /api browser-trust fence (rationale on + // Source 1b: authorities for the /api browser-trust fence (rationale on // resolveLanTrust). const ymlHost = (rows.get('webserver')?.config as { host?: string } | undefined)?.host const { lanAddresses, trustedHosts } = resolveLanTrust(this.options.host ?? ymlHost, this.options.trustedHosts ?? []) this.lanAddresses = lanAddresses if (trustedHosts.length > 0) put('connection', 'trustedHosts', trustedHosts) - // Source 3: the frontend dist — an assembly fact of this app, never yml + // Source 2: the frontend dist — an assembly fact of this app, never yml // user config. Workspace knowledge stays here. put('webserver', 'distIndex', this.resolveDistIndex()) @@ -262,7 +230,7 @@ export class AppCLIEntry { // One include of the shared base with every overlay as a sibling patch // list: patches never cross an include boundary, so nesting them would // silently stop reaching base rows. The surface overlay applies first, then - // this entry's profile-json and CLI-flag patches, which therefore win. + // this entry's CLI-flag patches, which therefore win. const compose = (overlay: PatchOptions[]): PatchOptions[] => [ ...loadOverlayPatches('dsh', this.options.overlayPath), ...overlay, @@ -327,22 +295,6 @@ export class AppCLIEntry { return doc as { id?: string; config?: unknown; insert?: { id?: string; config?: unknown }[] }[] } - /** Profile json under cwd; read-only — never created here, absent = no user config. */ - private readProfile(): Record { - let raw: string - try { - raw = readFileSync(join(process.cwd(), PROFILE_DIR, PROFILE_FILE), 'utf8') - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return {} - throw error - } - const parsed: unknown = JSON.parse(raw) - if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { - throw new Error(`dsh: ${PROFILE_DIR}/${PROFILE_FILE} must hold a JSON object`) - } - return parsed as Record - } - /** Dist location is workspace knowledge of this app: resolved through the frontend package exports, not configured. */ private resolveDistIndex(): string { const require = createRequire(import.meta.url) diff --git a/docs/user/guide/config.i18n.yaml b/docs/user/guide/config.i18n.yaml index 4fd91343ac..6d1265e9f3 100644 --- a/docs/user/guide/config.i18n.yaml +++ b/docs/user/guide/config.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/user/guide/config.md -config.md: 0e2e0e7e7077adcacfaada1d038a0b1e63fcc0cd -config.zh.md: 850a841286fe77db9169738b0b155f008205a1a8 +config.md: 6f656b573490a08ec893f4d14b487e6082015049 +config.zh.md: d4bb30023df46845ea720f3e6a45184479df0e72 diff --git a/docs/user/guide/config.md b/docs/user/guide/config.md index 0e2e0e7e70..6f656b5734 100644 --- a/docs/user/guide/config.md +++ b/docs/user/guide/config.md @@ -50,7 +50,7 @@ Plugins load in file order. Place plugins that depend on services after the appl ## CLI overlays -The TUI composes `base.cordis.yml` and `tui.cordis.yml`, then applies one optional patch list. By default that final list is `~/.dsh/config.yaml`; `dsh --config ` replaces the personal list with the named overlay. `dsh --config-replace ` instead boots the named file as the complete tree, without shipped or personal layers. `dsh web --config ` adds its overlay after the shared base and Web surface defaults and before Web profile and CLI-flag patches. +The TUI composes `base.cordis.yml` and `tui.cordis.yml`, then applies one optional patch list. By default that final list is `~/.dsh/config.yaml`; `dsh --config ` replaces the personal list with the named overlay. `dsh --config-replace ` instead boots the named file as the complete tree, without shipped or personal layers. `dsh web --config ` adds its overlay after the shared base and Web surface defaults and before the Web launcher's CLI-flag patches. A patch replaces a row's entire `config` value; it does not deep-merge keys. For example, patching `llm-deepseek` with only `config: { thinking: disabled }` also removes that row's configured `apiKey` and `baseURL`, so restate every key the row must retain. diff --git a/docs/user/guide/config.zh.md b/docs/user/guide/config.zh.md index 850a841286..d4bb30023d 100644 --- a/docs/user/guide/config.zh.md +++ b/docs/user/guide/config.zh.md @@ -50,7 +50,7 @@ Harness 使用 `cordis.yml` 描述 Agent 加载哪些插件以及每个插件的 ## CLI 覆盖层 -TUI 先组合 `base.cordis.yml` 与 `tui.cordis.yml`,再应用一个可选补丁列表。默认的最后一层是 `~/.dsh/config.yaml`;`dsh --config ` 会以指定覆盖替代个人补丁列表。`dsh --config-replace ` 则把指定文件作为完整配置树启动,不使用已交付配置或个人层。`dsh web --config ` 会在共享基础配置与 Web 界面默认值之后、Web profile 与命令行标志补丁之前添加覆盖。 +TUI 先组合 `base.cordis.yml` 与 `tui.cordis.yml`,再应用一个可选补丁列表。默认的最后一层是 `~/.dsh/config.yaml`;`dsh --config ` 会以指定覆盖替代个人补丁列表。`dsh --config-replace ` 则把指定文件作为完整配置树启动,不使用已交付配置或个人层。`dsh web --config ` 会在共享基础配置与 Web 界面默认值之后、Web 启动器的命令行标志补丁之前添加覆盖。 补丁会替换目标行的整个 `config` 值,而不是深度合并各个键。例如,只用 `config: { thinking: disabled }` 修补 `llm-deepseek`,也会移除该行原有的 `apiKey` 与 `baseURL`;因此必须重新写出该行需要保留的全部键。 From 03b534de1650255f5911eb79f3e44ada2bb37ed5 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 4 Aug 2026 14:50:38 +0800 Subject: [PATCH 02/27] feat(credentials): move the store to .credentials.yaml and layer $DSH_HOME/.env MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit $DSH_HOME/.env carried two incompatible jobs. As credentials-local's writable secret store it could not be hoisted into process.env — hoisting makes every stored key read as a read-only launch override and blocks rotation from the TUI and the web page. But its name and dotenv format promise an environment file, so a DEEPSEEK_BASE_URL sitting beside a working DEEPSEEK_API_KEY in the same file was silently ignored: only the credential provider read the document, and it addresses credential references alone. Split the two jobs into two files. .credentials.yaml is the provider-managed store: a strict YAML mapping of CredentialRef to non-empty string, no version field, no wrapper level. Because it holds credentials and nothing else, a non-mapping root, a non-identifier key, a non-string value, an empty string, a duplicate key, and malformed YAML are all rejections rather than skipped entries — loud at boot and at a write, warn-and-keep-last-good on a live reload. The dotenv physical-line editor gives way to a patch of the parsed document, so comments and untouched entries keep their formatting and any string value round-trips, multi-line included. Writer lock, read-modify-write, atomic 0600 write under a 0700 directory, watcher, self-write suppression, and quiescent disposal are unchanged. $DSH_HOME/.env becomes the user's ordinary environment layer. app-boot's new loadLayeredEnv loads the invoking directory's .env then the Harness home's, giving user < project < inherited; the home resolves from the inherited environment first, so a project .env cannot redirect it. Credential precedence is unchanged: the live environment still wins read-only over the file, and shadowed writes still reject. Whether a provider-managed store should instead win over the environment is a separate decision. No migration: a key already in $DSH_HOME/.env keeps resolving through the new environment layer, as a read-only env source that shadows the stored one. --- ...est-level-llm-config-credentials.i18n.yaml | 4 +- ...29-request-level-llm-config-credentials.md | 2 +- ...request-level-llm-config-credentials.zh.md | 2 +- ...undaries-and-atomic-registration.i18n.yaml | 4 +- ...tial-boundaries-and-atomic-registration.md | 2 +- ...l-boundaries-and-atomic-registration.zh.md | 2 +- ...-yaml-and-user-environment-layer.i18n.yaml | 6 + ...entials-yaml-and-user-environment-layer.md | 50 ++++ ...ials-yaml-and-user-environment-layer.zh.md | 50 ++++ THIRD_PARTY_NOTICES.md | 1 - apps/cli/config/base.cordis.yml | 9 +- apps/cli/src/app-cli-entry.ts | 6 +- apps/cli/src/bin.ts | 4 +- apps/cli/src/tui.ts | 11 +- apps/cli/tests/tui-keyless-smoke.e2e.ts | 31 +-- apps/web/tests/models-settings.e2e.ts | 10 +- .../tests/onboarding-deepseek-config.e2e.ts | 4 +- docs/config-catalog.md | 4 +- examples/headless-agent/cordis.yml | 2 +- packages/credentials/README.i18n.yaml | 4 +- packages/credentials/README.md | 2 +- packages/credentials/README.zh.md | 2 +- .../credentials-local/README.i18n.yaml | 4 +- .../credentials/credentials-local/README.md | 21 +- .../credentials-local/README.zh.md | 21 +- .../credentials-local/package.json | 4 +- .../credentials-local/src/index.ts | 250 +++++++----------- .../credentials-local/tests/drain.spec.ts | 2 +- .../credentials-local/tests/local.spec.ts | 161 ++++++----- .../tests/review-fixes.spec.ts | 99 ++----- .../credentials-local/tests/watcher.spec.ts | 55 ++-- .../llm-deepseek/tests/dynamic-config.spec.ts | 8 +- .../tests/loader-composition.spec.ts | 20 +- .../llm-pi-ai/tests/dynamic-config.spec.ts | 6 +- .../tests/loader-composition.spec.ts | 6 +- packages/ui/app-boot/README.i18n.yaml | 4 +- packages/ui/app-boot/README.md | 5 +- packages/ui/app-boot/README.zh.md | 5 +- packages/ui/app-boot/src/index.ts | 32 ++- packages/ui/app-boot/tests/app-boot.spec.ts | 62 ++++- pnpm-lock.yaml | 12 +- 41 files changed, 566 insertions(+), 423 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.md create mode 100644 .agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.zh.md 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 index c7861321a0..ddb3a064d4 100644 --- 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 @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md -2026-07-29-request-level-llm-config-credentials.md: f12a2496a767decc3ce2b065f6be03009aec8992 -2026-07-29-request-level-llm-config-credentials.zh.md: 99fd90013a24746962ca02a5f4f18cdccd53f71a +2026-07-29-request-level-llm-config-credentials.md: 5359865d1ca0c6620f4af1fa82c2f7e5413e79d6 +2026-07-29-request-level-llm-config-credentials.zh.md: e23bf92a0d8efa68ad682e002f07732aaa114049 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 index f12a2496a7..5359865d1c 100644 --- 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 @@ -14,7 +14,7 @@ The [settings seam](2026-07-28-user-settings-seam.md) shipped without a producti **Per-request resolution, not fiber rebuilds.** The adapters take an options thunk (and a per-stream credential resolver) instead of frozen construction facts, resolving once per operation — the Pi pattern, with its tested semantics: two requests straddling a change see two configurations, one request resolves exactly once, and an in-flight stream keeps the facts it started with. This deletes the entire swap machinery a rebuild design needs (`DUPLICATE_ADAPTER` ordering, `NO_ADAPTER` windows, a deferred-activation state machine) and makes a missing key a *request-time* actionable failure (`MISSING_CREDENTIAL` naming every entry point) while the route stays registered and the catalog stays browsable. The one registration-captured fact — the retry policy the `ctx.llm` registry snapshots at `registerAdapter` (plus pi-ai's route *set*) — re-registers the same adapter instance in one synchronous section when it changes. -**Secrets are references, values live behind `ctx.credentials`.** Configuration (both planes) carries `apiKeyEnv: DEEPSEEK_API_KEY`; the three-package credential seam resolves it per operation. `credentials-local` layers the live process environment (read-only, wins — a launch-time override is operator intent and must be *visibly* read-only, so shadowed writes reject instead of appearing to succeed) over `$DSH_HOME/.env` (writable, byte-preserving line edits, a quoting ladder dotenv reads back verbatim, wholesale snapshot replacement on reload so a deleted entry never lingers — the Claude Code additive-reapply lesson). Resolution order in the adapters is literal `apiKey` first (preserving the historical `config.apiKey ?? env` observable semantics), then the seam, then — only without a mounted seam — the raw environment variable. +**Secrets are references, values live behind `ctx.credentials`.** Configuration (both planes) carries `apiKeyEnv: DEEPSEEK_API_KEY`; the three-package credential seam resolves it per operation. `credentials-local` layers the live process environment (read-only, wins — a launch-time override is operator intent and must be *visibly* read-only, so shadowed writes reject instead of appearing to succeed) over the provider-managed document (writable, wholesale snapshot replacement on reload so a deleted entry never lingers — the Claude Code additive-reapply lesson). That document was `$DSH_HOME/.env` in dotenv form; the [credentials document split](2026-08-04-credentials-yaml-and-user-environment-layer.md) later moved it to `$DSH_HOME/.credentials.yaml` and freed the old path to become the user's environment layer. Resolution order in the adapters is literal `apiKey` first (preserving the historical `config.apiKey ?? env` observable semantics), then the seam, then — only without a mounted seam — the raw environment variable. **Per-plugin namespaces, schema ≡ `Config`.** Each adapter registers its own namespace (`llm-deepseek`, `llm-pi-ai`) with its plugin `Config` schema and its `cordis.yml` entry as the composition `base` — a settings section is the same YAML shape as the entry config, and `resolveAdapterOptions`/`resolveProfiles` stay the one explicit resolve step for both. A live snapshot failing a beyond-schema bound keeps the last good facts (the seam's last-good philosophy extended one level up); the entry config itself still fails load. pi-ai's `providers` became a dict keyed by route so base and user layers merge per provider and the route set is structural; the array shape fails loud with migration directions, and an empty dict is the valid dormant posture — a composition ships the adapter bare and every route stays a user-plane decision. 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 index 99fd90013a..e23bf92a0d 100644 --- 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 @@ -14,7 +14,7 @@ Status: implemented **按请求解析,而非重建 fiber。**适配器改为接收一个 options thunk(外加按流调用的凭据解析器),不再持有冻结的构造期事实,每个操作解析一次——即 Pi 的模式,连同其经测试固定的语义:跨越一次变更的两个请求看到两份配置,一个请求恰好解析一次,进行中的流保持其起始事实。这删掉了重建式设计所需的整套切换机制(`DUPLICATE_ADAPTER` 顺序问题、`NO_ADAPTER` 窗口、延迟激活状态机),并把密钥缺失变成*请求时*可行动的失败(`MISSING_CREDENTIAL` 点名每个配置入口),同时路由保持注册、catalog 保持可浏览。唯一在注册期捕获的事实——`ctx.llm` 注册表在 `registerAdapter` 时快照的重试策略(外加 pi-ai 的路由*集合*)——在其变化时于一个同步区段内原地重新注册同一适配器实例。 -**机密是引用,值藏在 `ctx.credentials` 背后。**配置(两个面)携带 `apiKeyEnv: DEEPSEEK_API_KEY`;三包凭据 seam 按操作解析它。`credentials-local` 把活跃进程环境(只读、优先——启动时覆盖是操作者意图,必须*可见地*只读,因此被遮蔽的写入直接拒绝而不是表面成功)叠加在 `$DSH_HOME/.env` 之上(可写、保字节行级编辑、dotenv 能逐字读回的引号阶梯、重载时整体替换快照使删除的条目绝不滞留——来自 Claude Code 增量重放(additive reapply)的教训)。适配器内的解析顺序为:字面 `apiKey` 优先(保留历史 `config.apiKey ?? env` 的可观察语义),然后是 seam,最后——仅在未挂载 seam 时——原始环境变量。 +**机密是引用,值藏在 `ctx.credentials` 背后。**配置(两个面)携带 `apiKeyEnv: DEEPSEEK_API_KEY`;三包凭据 seam 按操作解析它。`credentials-local` 把活跃进程环境(只读、优先——启动时覆盖是操作者意图,必须*可见地*只读,因此被遮蔽的写入直接拒绝而不是表面成功)叠加在 provider 管理的文档之上(可写、重载时整体替换快照使删除的条目绝不滞留——来自 Claude Code 增量重放(additive reapply)的教训)。该文档当时是 dotenv 形式的 `$DSH_HOME/.env`;[凭据文档拆分](2026-08-04-credentials-yaml-and-user-environment-layer.md)后来把它移到 `$DSH_HOME/.credentials.yaml`,并让旧路径转为用户的环境层。适配器内的解析顺序为:字面 `apiKey` 优先(保留历史 `config.apiKey ?? env` 的可观察语义),然后是 seam,最后——仅在未挂载 seam 时——原始环境变量。 **按插件划分 namespace,schema ≡ `Config`。**每个适配器注册自己的 namespace(`llm-deepseek`、`llm-pi-ai`),schema 用其插件 `Config` schema,组合 `base` 用其 `cordis.yml` 条目——settings 分节与 entry 配置是同一种 YAML 形状,`resolveAdapterOptions`/`resolveProfiles` 对两者仍是唯一的显式 resolve 步骤。存活快照若违反 schema 之外的约束,则保留最后可用事实(seam 的最后可用值哲学向上延伸一层);entry 配置本身仍会加载失败。pi-ai 的 `providers` 改为以路由为键的字典,base 层与用户层因此按提供方合并,路由集合也由结构直接表达;数组形状响亮失败并给出迁移指引,而空字典是合法的休眠姿态——组合可以裸挂该适配器,把每一条路由都留给用户面决定。 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 index 98f2b0cb0d..a4ac2f47bb 100644 --- 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 @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md -2026-07-30-credential-boundaries-and-atomic-registration.md: 6fe5f554acbfd804db9625fcaa794d513c8799c4 -2026-07-30-credential-boundaries-and-atomic-registration.zh.md: 3eb3b022064124aad2a389abba3063af4e2110fa +2026-07-30-credential-boundaries-and-atomic-registration.md: a093a78d7e3dafe218eb8f1013f226de0d6d9a0b +2026-07-30-credential-boundaries-and-atomic-registration.zh.md: 208642af34b5bda07a4e02bc991a655c5bb1fa20 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 index 6fe5f554ac..a093a78d7e 100644 --- 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 @@ -14,7 +14,7 @@ Two request-path defects sat beside them. DeepSeek's per-request resolution kept ## Decision -**`$DSH_HOME/.env` belongs to the credential provider alone.** No surface loads it into `process.env`. The genuine launch environment and the invoking directory's `.env` (loaded by the bin) stay the read-only ambient layer, so a composition without the provider resolves keys exactly as before, while a stored key stays file-sourced and writable across restarts — proven by a real restart in the loader composition rather than by a unit assertion about `describe()`. +**The credential document belongs to the credential provider alone.** No surface loads it into `process.env`. It was `$DSH_HOME/.env` here; the [credentials document split](2026-08-04-credentials-yaml-and-user-environment-layer.md) later moved it to `$DSH_HOME/.credentials.yaml`, so today it is the old path that is loaded — as the user's ordinary environment layer, holding no provider-managed secret. The genuine launch environment and the invoking directory's `.env` (loaded by the bin) stay the read-only ambient layer, so a composition without the provider resolves keys exactly as before, while a stored key stays file-sourced and writable across restarts — proven by a real restart in the loader composition rather than by a unit assertion about `describe()`. **The stored credential has no boundary against the model, and the READMEs say so.** `0600` under a `0700` directory stops other OS users; the model's bash and filesystem tools run as that same user, and the shipped default confines nothing. What the harness does hold to is narrower and stated as exactly that: no surface hoists the document into `process.env`, and the model is never handed a resolved path to it, so reaching the value takes a deliberate read of a path it was not given. An OS-keychain provider — a store the model's processes cannot read at all — is recorded as the real answer rather than implied by a partial one. 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 index 3eb3b02206..208642af34 100644 --- 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 @@ -18,7 +18,7 @@ Status: implemented ## 决策 -**`$DSH_HOME/.env` 只归凭据提供方所有。**没有任何一个面会把它加载进 `process.env`。真正的启动环境,以及调用目录中由 bin 加载的 `.env`,仍然是那一层只读的环境来源,因此不挂载该提供方的组合,解析密钥的方式与从前完全一致,而存下的密钥跨重启仍然来源于文件、仍然可写——这一点由 Loader 组合中的一次真实重启来证明,而不是靠对 `describe()` 的单元断言。 +**凭据文档只归凭据提供方所有。**没有任何一个面会把它加载进 `process.env`。当时该文档是 `$DSH_HOME/.env`;[凭据文档拆分](2026-08-04-credentials-yaml-and-user-environment-layer.md)后来把它移到 `$DSH_HOME/.credentials.yaml`,因此如今被加载的正是那条旧路径——作为用户的普通环境层,其中不含任何 provider 管理的密钥。真正的启动环境,以及调用目录中由 bin 加载的 `.env`,仍然是那一层只读的环境来源,因此不挂载该提供方的组合,解析密钥的方式与从前完全一致,而存下的密钥跨重启仍然来源于文件、仍然可写——这一点由 Loader 组合中的一次真实重启来证明,而不是靠对 `describe()` 的单元断言。 **存下的凭据对模型没有边界,而 README 就是这么写的。**`0700` 目录下的 `0600` 挡得住其他 OS 用户;模型的 bash 与文件系统工具正是以同一用户身份运行,而已交付的默认值不约束任何东西。harness 真正守住的更窄,也就照这个宽度写下来:没有任何一个面会把该文档提升进 `process.env`,模型也从不会拿到它的解析后路径,因此要拿到这个值,需要刻意去读一条并未交给它的路径。OS 钥匙串(keychain)提供方——一个模型的进程根本读不到的存储——被记录为真正的答案,而不是靠一个残缺的方案去暗示它。 diff --git a/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.i18n.yaml new file mode 100644 index 0000000000..eb74fbd0e2 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.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-08-04-credentials-yaml-and-user-environment-layer.md +2026-08-04-credentials-yaml-and-user-environment-layer.md: f1bca69820d03fe67849bd7c7159489ac27cd2e0 +2026-08-04-credentials-yaml-and-user-environment-layer.zh.md: 7e6714abd33baad1fb2a570514754b467fcf8bd5 diff --git a/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.md b/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.md new file mode 100644 index 0000000000..f1bca69820 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.md @@ -0,0 +1,50 @@ +# Agent Note: Splitting the credential store from the user environment layer + +Status: implemented + +English | [中文](2026-08-04-credentials-yaml-and-user-environment-layer.zh.md) + +## Problem + +`$DSH_HOME/.env` carried two incompatible jobs. It was the writable secret store of [`credentials-local`](../../../../packages/credentials/credentials-local/README.md), so no surface could hoist it into `process.env` — hoisting would make every stored key read as a read-only launch override and block rotation from the TUI and the web page. But its name and dotenv format promise an environment file, so users put non-secrets in it and those values reached nothing: a `DEEPSEEK_BASE_URL` beside a working `DEEPSEEK_API_KEY` in the same file was silently ignored, because only the credential provider read the document and it addresses credential references alone. + +One file cannot be both a store the Harness owns and isolates and a layer that propagates by ordinary environment rules. The [request-level credential decision](2026-07-29-request-level-llm-config-credentials.md) chose dotenv to match peer products' home `.env`, and the conflation was not visible until a non-secret needed the same file. + +## Decision + +The two jobs become two files under the Harness home. + +**`.credentials.yaml` is the provider-managed store.** A strict YAML mapping of `CredentialRef` to non-empty string, with no `version` field and no wrapper level: + +```yaml +DEEPSEEK_API_KEY: sk-… +OPENAI_API_KEY: sk-… +``` + +Because the document holds credentials and nothing else, every deviation is a rejection rather than a skipped entry: a non-mapping root, a key that is not a POSIX identifier, a non-string value, an empty string, a duplicate key, and malformed YAML all fail — loud at boot and at a write, warn-and-keep-the-last-good-snapshot on a live reload. A silently ignored key would read as "the secret I stored has no effect", which is the failure this change exists to remove. The dotenv physical-line editor is replaced by a patch of the parsed document, so comments and untouched entries keep their formatting, any string value round-trips (multi-line included), and no entry is unwritable for want of a quoting style. The writer lock, read-modify-write, atomic `0600` write under a `0700` directory, exact-path watcher, content-equality self-write suppression, and quiescent disposal are unchanged. + +**`$DSH_HOME/.env` is the user's ordinary environment layer.** `loadLayeredEnv` in [`dsh-app-boot`](../../../../packages/ui/app-boot/README.md) loads the invoking directory's `.env` and then the Harness home's, giving `user < project < inherited` — `process.loadEnvFile` never replaces a name already set, which is what the load order exploits and what the app-boot tests pin across all three layers. The Harness home is resolved from the inherited environment *before* either file loads, so a project `.env` cannot redirect which user document is read. Only the product CLI layers these files; SDK and example bins keep loading their own directory through `loadEnv` and must not inherit a developer's `$DSH_HOME`. + +Credential precedence is unchanged this round: the live process environment still wins read-only over the file, and `set`/`unset` still reject a write the environment would shadow. Whether a provider-managed store should instead win over the environment is a separate decision, deliberately not taken here. + +There is no migration. The product is unreleased, and a key already in `$DSH_HOME/.env` keeps resolving through the new environment layer — as a read-only `env` source that shadows the stored one, which is exactly what the diagnostics say. + +## Consequences + +- Given up: a key left in `$DSH_HOME/.env` is now hoisted into `process.env`, so it reaches subprocesses under the [subprocess credential scrub](../../../../packages/subprocess/subprocess/README.md) rather than staying inside the provider. That is the honest meaning of "ordinary environment layer"; a secret the Harness should own and isolate belongs in `.credentials.yaml`, which is never hoisted. +- Given up: the same key shadows `.credentials.yaml` and makes the web Models page's write reject. The seam already reports `source: 'env', writable: false` for that state, and the rejection message now names the loaded `.env` as a place to unset it. +- Bought: a non-secret in the user's `.env` finally takes effect, which was the original defect; the document format can reject what it cannot serve; and `0600` covers a file that holds only secrets instead of a file users are told to put ordinary configuration in. +- Not taken: a read-time permission check that fails startup when `.credentials.yaml` is more permissive than `0600`. Creation and atomic replacement already pin the mode; making a hand-created file fatal is a separable security decision. +- The `0600` boundary still stops other OS users and not the model, unchanged by this split — the [provider README](../../../../packages/credentials/credentials-local/README.md) owns that limit and the keychain-provider deferral. + +## Alternatives considered + +**Keep one `$DSH_HOME/.env` and teach the CLI to hoist it.** Rejected: hoisting the store is precisely what makes stored keys unrotatable, which is why [app-boot documented the exclusion](../../../../packages/ui/app-boot/README.md) in the first place. The conflict is the file's two jobs, not the loader. + +**`$DSH_HOME/.credentials.env` — a second dotenv file.** Rejected: dotenv suits an environment layer but cannot express "a managed document indexed by credential reference". It cannot reject a non-string or an unaddressable key, and its line editor already refused values it could not quote, leaving entries readable but unwritable. + +**Add a `version` field to the new document.** Rejected: the format is one schema-constrained string mapping with no historical variant to discriminate. While the product is unreleased, changing the structure and rejecting the old one beats promising a migration protocol. + +**Migrate credential-shaped keys out of `$DSH_HOME/.env` on first run.** Rejected: migration code turns a short-lived format into a long-lived maintenance surface, and classifying which keys in an unknown file are secrets is exactly the ambiguity this split removes. The old file keeps working as environment, which is a truthful outcome rather than a silent one. + +**Drop the user `.env` layer entirely and keep only the inherited environment.** Rejected here as out of scope: it is a coherent design (fewer layers, one place per value), but it removes a workflow users have, and the layering question belongs with the deferred precedence decision rather than with this split. diff --git a/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.zh.md b/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.zh.md new file mode 100644 index 0000000000..7e6714abd3 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.zh.md @@ -0,0 +1,50 @@ +# Agent Note: 把凭据存储与用户环境层拆开 + +Status: implemented + +[English](2026-08-04-credentials-yaml-and-user-environment-layer.md) | 中文 + +## Problem + +`$DSH_HOME/.env` 同时承担了两件互不相容的工作。它是 [`credentials-local`](../../../../packages/credentials/credentials-local/README.md) 的可写密钥存储,因此任何表层都不能把它提升进 `process.env`——一旦提升,每个已存密钥都会读作只读的启动时覆盖,从而阻断从 TUI 与 Web 页面轮换密钥。但它的文件名和 dotenv 格式承诺的是一个环境文件,于是用户把非密钥值放进去,而那些值哪儿也到不了:同一个文件里,一个能用的 `DEEPSEEK_API_KEY` 旁边的 `DEEPSEEK_BASE_URL` 会被静默忽略,因为只有凭据 provider 读这份文档,而它只寻址凭据引用。 + +一个文件无法既是由 Harness 拥有并隔离的存储,又是按普通环境规则传播的层。[请求级凭据决策](2026-07-29-request-level-llm-config-credentials.md)当初选择 dotenv 是为了对齐同类产品的 home `.env`,而这种混同直到有非密钥值需要用同一个文件时才暴露出来。 + +## Decision + +两件工作在 Harness home 下拆成两个文件。 + +**`.credentials.yaml` 是 provider 管理的存储。** 一个从 `CredentialRef` 到非空字符串的严格 YAML mapping,没有 `version` 字段,也没有包装层: + +```yaml +DEEPSEEK_API_KEY: sk-… +OPENAI_API_KEY: sk-… +``` + +因为该文档只存放凭据、别无他物,任何偏离都是拒绝而不是跳过条目:非 mapping 的根、非 POSIX 标识符的键、非字符串值、空字符串、重复键以及格式错误的 YAML 全部失败——启动时和写入时响亮失败,运行期热重载则告警并保留最后可用快照。被静默忽略的键读起来就是「我存进去的密钥没有生效」,而这正是本次变更要消除的失败。dotenv 物理行编辑器被替换为对已解析文档打补丁,因此注释与未触及条目的排版都会保留,任何字符串值都能往返(含多行),也不会再有条目因为缺少可用引号样式而不可写。写锁、read-modify-write、`0700` 目录下的 `0600` 原子写、精确路径 watcher、按内容相等抑制自写、以及 dispose 时的完全停稳,均保持不变。 + +**`$DSH_HOME/.env` 是用户的普通环境层。** [`dsh-app-boot`](../../../../packages/ui/app-boot/README.md) 中的 `loadLayeredEnv` 先加载调用目录的 `.env`,再加载 Harness home 的,得到 `用户 < 项目 < 继承`——`process.loadEnvFile` 从不替换已经设置的名字,加载顺序正是利用了这一点,app-boot 的测试也把三层一起钉住。Harness home 在两个文件加载*之前*就从继承的环境解析完毕,因此项目 `.env` 无法改变读取哪份用户文档。只有产品 CLI(命令行界面)叠加这两个文件;SDK 与示例 bin 仍通过 `loadEnv` 加载各自的目录,绝不继承开发者的 `$DSH_HOME`。 + +本轮不改凭据优先级:活跃进程环境仍然只读地优先于文件,`set`/`unset` 仍然拒绝会被环境遮蔽的写入。provider 管理的存储是否应当反过来压过环境,是另一个决策,此处刻意不作。 + +不做迁移。产品尚未发布,而已经放在 `$DSH_HOME/.env` 里的密钥会继续通过新的环境层解析——作为只读的 `env` 来源遮蔽已存储的那一份,诊断给出的也正是这个结论。 + +## Consequences + +- 放弃的:留在 `$DSH_HOME/.env` 里的密钥现在会被提升进 `process.env`,因而会按[子进程凭据清洗](../../../../packages/subprocess/subprocess/README.md)的规则抵达子进程,而不再留在 provider 内部。这就是「普通环境层」的诚实含义;需要由 Harness 拥有并隔离的密钥属于 `.credentials.yaml`,后者永不提升。 +- 放弃的:同一个键会遮蔽 `.credentials.yaml`,并让 Web Models 页的写入被拒。seam 对这种状态本来就报告 `source: 'env', writable: false`,而拒绝信息现在会把已加载的 `.env` 一并指为需要清除的位置。 +- 换来的:用户 `.env` 里的非密钥值终于生效,这正是最初的缺陷;文档格式可以拒绝它无法承担的内容;`0600` 保护的是一个只存密钥的文件,而不是一个我们同时叫用户往里写普通配置的文件。 +- 未采纳的:在读取时校验权限、并在 `.credentials.yaml` 宽于 `0600` 时让启动失败。创建与原子替换已经钉住了模式;让手工创建的文件直接致命是一个可分离的安全决策。 +- `0600` 这条边界仍然只挡其他 OS 用户、挡不住模型,本次拆分未改变这一点——该限制及 keychain provider 的延后项归 [provider README](../../../../packages/credentials/credentials-local/README.md) 所有。 + +## Alternatives considered + +**保留单一的 `$DSH_HOME/.env`,让 CLI 去提升它。** 否决:提升存储本身正是让已存密钥无法轮换的原因,这也是 [app-boot 当初记录该排除](../../../../packages/ui/app-boot/README.md)的理由。冲突来自这个文件的两份工作,而不是加载器。 + +**`$DSH_HOME/.credentials.env`——第二个 dotenv 文件。** 否决:dotenv 适合环境层,却无法表达「一份按凭据引用索引的受管文档」。它无法拒绝非字符串或无法寻址的键,而且它的行编辑器本来就会拒绝无法加引号的值,留下可读却不可写的条目。 + +**给新文档加 `version` 字段。** 否决:该格式只有一个受 schema 约束的字符串 mapping,没有需要判别的历史变体。在未发布阶段,直接修改结构并拒绝旧结构,好过提前承诺迁移协议。 + +**首次运行时把形似凭据的键从 `$DSH_HOME/.env` 迁出。** 否决:迁移代码会把短命格式变成长期维护面,而判断一个未知文件里哪些键是密钥,恰恰是本次拆分要消除的歧义。旧文件继续作为环境工作,这是诚实的结果,而不是静默的结果。 + +**彻底取消用户 `.env` 层,只保留继承的环境。** 在此处否决为超出范围:它本身是自洽的设计(层次更少、每个值只有一处来源),但会移除用户已有的工作流,而分层问题属于那个被延后的优先级决策,不属于本次拆分。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 92ea0d2406..515004086e 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -52,7 +52,6 @@ External packages that a workspace package resolves at runtime. `scripts/install | [`clsx`](https://github.com/lukeed/clsx) | MIT | | [`commander`](https://github.com/tj/commander.js) | MIT | | [`diff`](https://github.com/kpdecker/jsdiff) | BSD-3-Clause | -| [`dotenv`](https://github.com/motdotla/dotenv) | BSD-2-Clause | | [`eventsource-parser`](https://github.com/rexxars/eventsource-parser) | MIT | | [`handlebars`](https://github.com/handlebars-lang/handlebars.js) | MIT | | [`immer`](https://github.com/immerjs/immer) | MIT | diff --git a/apps/cli/config/base.cordis.yml b/apps/cli/config/base.cordis.yml index b7860e2eaa..d46e103426 100644 --- a/apps/cli/config/base.cordis.yml +++ b/apps/cli/config/base.cordis.yml @@ -69,12 +69,13 @@ - id: settings name: '@deepseek-ai/dsh-settings-local' -# Credential store: the live process environment over `$DSH_HOME/.env` +# Credential store: the live process environment over `$DSH_HOME/.credentials.yaml` # (owner-only file, hot-reloaded). Adapters resolve their key references # through it at each request, so no key is inlined in this file. The web -# Models page's key inputs write it through `credentials.set`; nothing hoists -# the document into the process environment, which would make every stored key -# read as an unrotatable ambient override. +# Models page's key inputs write it through `credentials.set`. The document +# holds credentials only and is never hoisted into the process environment; +# the user's ordinary environment layer is `$DSH_HOME/.env`, and a key placed +# there instead reads as an unrotatable ambient override. - id: credentials name: '@deepseek-ai/dsh-credentials-local' diff --git a/apps/cli/src/app-cli-entry.ts b/apps/cli/src/app-cli-entry.ts index e46c8d653a..ba3105c3ef 100644 --- a/apps/cli/src/app-cli-entry.ts +++ b/apps/cli/src/app-cli-entry.ts @@ -4,9 +4,9 @@ * Everything here is what must exist before the Loader runs: the patch * composition over the shipped base and surface overlay (CLI flags + the * resolved frontend dist), and the fail-loud activation audit 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. + * settles. The environment is what the bin already loaded (ambient over the + * invoking directory's `.env` over `$DSH_HOME/.env`); credentials live in + * `$DSH_HOME/.credentials.yaml` and are never hoisted into it. */ import { readFileSync } from 'node:fs' diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts index 3886438bed..dd5642de10 100644 --- a/apps/cli/src/bin.ts +++ b/apps/cli/src/bin.ts @@ -10,7 +10,7 @@ import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' -import { loadEnv } from '@deepseek-ai/dsh-app-boot' +import { loadLayeredEnv } from '@deepseek-ai/dsh-app-boot' import { parseDshArgs } from './args.ts' // Both the source tree (apps/cli/src) and the bundled bin (apps/cli/lib) sit @@ -24,7 +24,7 @@ function readVersion(): string { return typeof manifest.version === 'string' ? manifest.version : '0.0.0' } -loadEnv('dsh') +loadLayeredEnv('dsh') // The env opt-in is read at the process boundary; `1` is the documented value. const invocation = parseDshArgs(process.argv.slice(2), readVersion(), process.env.DSH_EXPERIMENTAL === '1') diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index 5af1a32cbb..f91ea05c4e 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -115,12 +115,11 @@ export async function runTui( ) process.exit(1) } - // 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 + // The bin already loaded both environment files, and that is the whole + // environment: credentials live in `$DSH_HOME/.credentials.yaml`, which is + // never hoisted, so a stored key stays rotatable from the TUI and the web + // page. The environment is settled, so switching the workspace here cannot + // alter its precedence — the project layer is the *invoking* directory's. 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 17b33f37ce..ade38a0e9c 100644 --- a/apps/cli/tests/tui-keyless-smoke.e2e.ts +++ b/apps/cli/tests/tui-keyless-smoke.e2e.ts @@ -667,40 +667,41 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { expect(output).toContain('\u001B[?2004l') }, PTY_SMOKE_TEST_TIMEOUT_MS) - 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 + it('applies the personal overlay: config.yaml patches an overlay-inserted row, and both .env layers feed its !!js with the project one winning', async () => { + // The whole personal-config chain in one boot, plus the environment + // layering underneath it. 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. + // patch list reaches a row an earlier one inserted. The `!!js` expression + // renders both halves of the layering in one line: `DSH_LAYER_WELCOME` is + // set by BOTH .env files and must render the project value, while + // `DSH_USER_ONLY` exists only in the harness home's .env and must still + // arrive. Credentials are not part of this: they live in + // `.credentials.yaml`, which is never hoisted into `process.env`. 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' }, + workspace: { '.env': 'DSH_LAYER_WELCOME=PROJECT WINS.\n' }, personal: { - '.env': 'DSH_PERSONAL_WELCOME=HOME ENV LEAKED.\n', + '.env': 'DSH_LAYER_WELCOME=USER LAYER LOST.\nDSH_USER_ONLY=USER LAYER LOADED.\n', 'config.yaml': [ '- id: workspace-context', ' disabled: true', '- id: tui', ' config:', " sessionId: !!js configuredAgentIdentities?.main?.id ?? 'main'", - ' welcome: !!js process.env.DSH_PERSONAL_WELCOME ?? process.env.DSH_PROJECT_WELCOME', + ' welcome: !!js "(process.env.DSH_LAYER_WELCOME ?? \'PROJECT LAYER MISSING.\')' + + ' + \' \' + (process.env.DSH_USER_ONLY ?? \'USER LAYER MISSING.\')"', '', ].join('\n'), }, }), - actions: [{ waitFor: 'PROJECT OVERLAY READY.', send: '/exit\r' }], + actions: [{ waitFor: 'PROJECT WINS. USER LAYER LOADED.', send: '/exit\r' }], }) - expect(output).toContain('PROJECT OVERLAY READY.') - expect(output).not.toContain('HOME ENV LEAKED.') + expect(output).toContain('PROJECT WINS. USER LAYER LOADED.') + expect(output).not.toContain('USER LAYER LOST.') expect(output).toContain('\u001B[?2004l') }, PTY_SMOKE_TEST_TIMEOUT_MS) diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts index c46127c9db..33e27628b0 100644 --- a/apps/web/tests/models-settings.e2e.ts +++ b/apps/web/tests/models-settings.e2e.ts @@ -81,7 +81,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => { await dialog.getByLabel('API 密钥').fill('sk-e2e-minimax') await dialog.getByRole('button', { name: '保存', exact: true }).click() // The profile lands in settings.yaml with only the derived reference, the - // key value lands in the harness home's .env, the dormant route + // key value lands in the harness home's .credentials.yaml, the dormant route // registers, and the topology frame invalidates the page into the row. const row = dialog.getByText('minimax-cn', { exact: true }).first() await row.waitFor({ timeout: 10_000 }) @@ -89,8 +89,8 @@ describe('web e2e: Models settings page configures a dormant provider', () => { expect(document).toContain('minimax-cn:') expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY') expect(document).not.toContain('sk-e2e-minimax') - const stored = await readFile(join(scaffold.harnessHome, '.env'), 'utf8') - expect(stored).toContain('MINIMAX_CN_API_KEY=sk-e2e-minimax') + const stored = await readFile(join(scaffold.harnessHome, '.credentials.yaml'), 'utf8') + expect(stored).toContain('MINIMAX_CN_API_KEY: sk-e2e-minimax') expect(await page.content()).not.toContain('sk-e2e-minimax') }, 60_000) @@ -136,8 +136,8 @@ describe('web e2e: Models settings page configures a dormant provider', () => { async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 10_000 }, ).not.toContain('minimax-cn:') - expect(await readFile(join(scaffold.harnessHome, '.env'), 'utf8')) - .toContain('MINIMAX_CN_API_KEY=sk-e2e-minimax') + expect(await readFile(join(scaffold.harnessHome, '.credentials.yaml'), 'utf8')) + .toContain('MINIMAX_CN_API_KEY: sk-e2e-minimax') await expect.poll( async () => page.getByRole('dialog', { name: '删除模型提供方?' }).count(), { timeout: 10_000 }, diff --git a/apps/web/tests/onboarding-deepseek-config.e2e.ts b/apps/web/tests/onboarding-deepseek-config.e2e.ts index 1ec36454d0..78dd8bf7da 100644 --- a/apps/web/tests/onboarding-deepseek-config.e2e.ts +++ b/apps/web/tests/onboarding-deepseek-config.e2e.ts @@ -112,8 +112,8 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup await settings.getByRole('button', { name: '保存', exact: true }).click() await keyInput.waitFor({ state: 'detached', timeout: 15_000 }) - const stored = await readFile(join(scaffold.harnessHome, '.env'), 'utf8') - expect(stored.includes(`DEEPSEEK_API_KEY=${secret}`)).toBe(true) + const stored = await readFile(join(scaffold.harnessHome, '.credentials.yaml'), 'utf8') + expect(stored.includes(`DEEPSEEK_API_KEY: ${secret}`)).toBe(true) expect((await page.content()).includes(secret)).toBe(false) expect((await page.locator('body').ariaSnapshot()).includes(secret)).toBe(false) expect(browserConsole.some(line => line.includes(secret))).toBe(false) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 617cfb82be..ab0ca22024 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -412,7 +412,7 @@ Source: [`packages/compact/compact-tool-result-prune/src/types.ts:4`](../package ```ts config-catalog /** Plugin config: file location and hot-reload behavior. */ export interface Config { - /** Credentials document path; defaults to `.env` under the harness home. */ + /** Credentials document path; defaults to `.credentials.yaml` under the harness home. */ path?: string /** Harness home used when `path` is omitted; defaults to `$DSH_HOME` or `~/.dsh`. */ dshHome?: string @@ -423,7 +423,7 @@ export interface Config { } ``` -Source: [`packages/credentials/credentials-local/src/index.ts:26`](../packages/credentials/credentials-local/src/index.ts) +Source: [`packages/credentials/credentials-local/src/index.ts:35`](../packages/credentials/credentials-local/src/index.ts) ## `@deepseek-ai/dsh-fs-local` diff --git a/examples/headless-agent/cordis.yml b/examples/headless-agent/cordis.yml index 937c976c67..25fc5ea0a2 100644 --- a/examples/headless-agent/cordis.yml +++ b/examples/headless-agent/cordis.yml @@ -9,7 +9,7 @@ - id: settings name: '@deepseek-ai/dsh-settings-local' -# Credential store: the live process environment over `$DSH_HOME/.env` +# Credential store: the live process environment over `$DSH_HOME/.credentials.yaml` # (owner-only file, hot-reloaded). The adapter resolves `DEEPSEEK_API_KEY` # through it at each request, so no key is inlined in this file. - id: credentials diff --git a/packages/credentials/README.i18n.yaml b/packages/credentials/README.i18n.yaml index e8b35ba48e..e62ea8db5c 100644 --- a/packages/credentials/README.i18n.yaml +++ b/packages/credentials/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/credentials/README.md -README.md: 1d450cbeef84750fa57ca0151563c496aed0ce12 -README.zh.md: 843230c3cebf35f234d3ad812165b16ea734678b +README.md: 4ab315e01a30d55869dbbb27dfbaf0f318eadd9f +README.zh.md: 736f7f02eb26b7e0931b676b854dd108fdfae3eb diff --git a/packages/credentials/README.md b/packages/credentials/README.md index 1d450cbeef..4ab315e01a 100644 --- a/packages/credentials/README.md +++ b/packages/credentials/README.md @@ -7,7 +7,7 @@ The credential capability seam, as three-package shape dictates (interface / imp | 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) | +| [`credentials-local/`](credentials-local/README.md) | File/environment provider: the live process environment (read-only, wins) layered over `$DSH_HOME/.credentials.yaml` (writable, comment-preserving 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. diff --git a/packages/credentials/README.zh.md b/packages/credentials/README.zh.md index 843230c3ce..736f7f02eb 100644 --- a/packages/credentials/README.zh.md +++ b/packages/credentials/README.zh.md @@ -7,7 +7,7 @@ | 包 | 角色 | |---|---| | [`credentials/`](credentials/README.md) | 抽象 `ctx.credentials`:品牌化 `CredentialRef` 引用、按操作 `resolve`、对 UI 安全的 `describe`、响亮失败的 `set`/`unset`,以及 `credentials/updated` 提交事件 | -| [`credentials-local/`](credentials-local/README.md) | 文件/环境 provider:活跃进程环境(只读、优先)叠加在 `$DSH_HOME/.env`(可写、保字节行级编辑、热重载)之上 | +| [`credentials-local/`](credentials-local/README.md) | 文件/环境 provider:活跃进程环境(只读、优先)叠加在 `$DSH_HOME/.credentials.yaml`(可写、保留注释的编辑、热重载)之上 | 配置文件携带的是对机密的*引用*(`apiKeyEnv: DEEPSEEK_API_KEY`),绝不携带机密本身:设置文档可以放心同步与渲染,轮换值不触碰任何配置。LLM 适配器是第一批消费方——它们每次模型请求解析一次引用,正因如此,片刻前存入的密钥无需重启任何组件即可作用于紧随其后的下一次请求。 diff --git a/packages/credentials/credentials-local/README.i18n.yaml b/packages/credentials/credentials-local/README.i18n.yaml index b5fb4b2f0e..fc89d359e8 100644 --- a/packages/credentials/credentials-local/README.i18n.yaml +++ b/packages/credentials/credentials-local/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/credentials/credentials-local/README.md -README.md: 02b883958faf8b695a3a2abf2df77790cc2fca86 -README.zh.md: 59c7fd5747f327e8998882ca4db1473173e793b5 +README.md: ca2af9d8a514b43aeef19abec7cda4e44645bdaf +README.zh.md: a8be53629853fe6fb7c39ef2281ac798b5624010 diff --git a/packages/credentials/credentials-local/README.md b/packages/credentials/credentials-local/README.md index 02b883958f..ca2af9d8a5 100644 --- a/packages/credentials/credentials-local/README.md +++ b/packages/credentials/credentials-local/README.md @@ -7,7 +7,7 @@ File-backed [credentials](../credentials/README.md) provider: two layers, one ho | Layer | Source id | Writable | Wins | |---|---|---|---| | Live process environment | `env` | no | always | -| `$DSH_HOME/.env` document | `file` | yes (`set`/`unset`) | otherwise | +| `$DSH_HOME/.credentials.yaml` 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. @@ -15,24 +15,33 @@ The environment wins because a launch-time override (`DEEPSEEK_API_KEY=… dsh`, | Field | Default | Meaning | |---|---|---| -| `path` | `/.env` | Credentials document location. | +| `path` | `/.credentials.yaml` | Credentials document location. | | `dshHome` | `$DSH_HOME` or `~/.dsh` | Harness home used when `path` is omitted. | | `watch` | `true` | Hot-publish external edits. | | `debounceMs` | `100` | Watcher write-settle window. | ## The document -dotenv format, parsed with `dotenv` and edited by a physical-line editor that preserves every byte it does not own: `set` rewrites the first assignment of its key in place with that line's own ending (dropping later duplicates, which dotenv's last-wins reading would otherwise let override the edit), `unset` removes only the owning line, and comments, unrelated lines, CRLF endings, and the continuation lines of another key's quoted multi-line value all survive verbatim. Every write first re-reads the document under the cross-process writer lock of [`dsh-atomic-write`](../../util/atomic-write/README.md) and publishes anything it had not observed, then commits atomically with mode `0600` under an owner-only (`0700`) directory — so a concurrent writer or an external edit inside the watcher's debounce window is folded in rather than overwritten. +A YAML mapping of credential reference to value, and nothing else: -Values are rendered in the narrowest style dotenv reads back verbatim — bare, then single-quoted (fully literal), then double-quoted (only without backslashes, which double-quote reading expands). A value no style can represent, and any entry that already spans multiple physical lines, fails loud instead of being corrupted silently. An empty stored value is absent, per the seam rule. +```yaml +DEEPSEEK_API_KEY: sk-… +OPENAI_API_KEY: sk-… +``` + +The document holds credentials only, so every deviation is a rejection rather than a skipped entry — a silently ignored key would read as "the secret I stored has no effect". A non-mapping root, a key that is not a POSIX identifier, a non-string value, an empty string, a duplicate key, and malformed YAML all fail: loud at boot, and warn-and-keep-the-last-good-snapshot on a live reload. There is no `version` field and no wrapper level; the format is the mapping. + +Writes patch the parsed document rather than rebuilding it, so comments and the formatting of every untouched entry survive. A comment directly above an entry is that entry's annotation and is removed with it. Every write first re-reads the document under the cross-process writer lock of [`dsh-atomic-write`](../../util/atomic-write/README.md) and publishes anything it had not observed, then commits atomically with mode `0600` under an owner-only (`0700`) directory — so a concurrent writer or an external edit inside the watcher's debounce window is folded in rather than overwritten. An on-disk document that no longer parses fails the write instead of overwriting content the provider could not understand. + +Any string value round-trips, multi-line values included, so no entry is unwritable for want of a quoting style. An empty stored value is absent, per the seam rule — which is why an empty string in the document is rejected outright: `unset` removes a key, it does not blank it. ## Hot reload -External edits publish `credentials/updated` per changed reference after the snapshot is replaced **wholesale** — an entry deleted on disk never lingers in memory. The provider's own writes are recognized by content and publish exactly their one commit event. An unreadable document at runtime keeps the last good snapshot and warns; an absent file is an empty store; an unreadable file at boot fails loud. Keys that are not POSIX identifiers are preserved file content the seam cannot address. +External edits publish `credentials/updated` per changed reference after the snapshot is replaced **wholesale** — an entry deleted on disk never lingers in memory. The provider's own writes are recognized by content and publish exactly their one commit event. An unreadable or invalid document at runtime keeps the last good snapshot and warns; an absent file is an empty store; an unreadable or invalid file at boot fails loud. ## Security boundary -The document is `0600` under a `0700` directory, which stops other OS users — **not** the model. Tool processes (bash, the filesystem tools) run as the same user, and the shipped `workspace-write` file policy confines mutations rather than reads, so they can read this file exactly like any other file the user owns; no sandbox mode singles it out. What the harness does hold to is narrower: it never hands the model a resolved path to the document, and never loads it into the process environment (see [app-boot's 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. +The document is `0600` under a `0700` directory, which stops other OS users — **not** the model. Tool processes (bash, the filesystem tools) run as the same user, and the shipped `workspace-write` file policy confines mutations rather than reads, so they can read this file exactly like any other file the user owns; no sandbox mode singles it out. What the harness does hold to is narrower: it never hands the model a resolved path to the document, and never loads it into the process environment — unlike `$DSH_HOME/.env`, which is the user's ordinary environment layer (see [app-boot's 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. diff --git a/packages/credentials/credentials-local/README.zh.md b/packages/credentials/credentials-local/README.zh.md index 59c7fd5747..a8be536298 100644 --- a/packages/credentials/credentials-local/README.zh.md +++ b/packages/credentials/credentials-local/README.zh.md @@ -7,7 +7,7 @@ | 层 | 来源 id | 可写 | 优先 | |---|---|---|---| | 活跃进程环境 | `env` | 否 | 恒定优先 | -| `$DSH_HOME/.env` 文档 | `file` | 是(`set`/`unset`) | 其余情况 | +| `$DSH_HOME/.credentials.yaml` 文档 | `file` | 是(`set`/`unset`) | 其余情况 | 环境优先,因为启动时覆盖(`DEEPSEEK_API_KEY=… dsh`、CI 机密、加载了仓库 `.env` 的开发 shell)代表本次运行的操作者意图——而它无法从进程内部修改,就必须*可见地*只读:`describe()` 报告 `source: 'env', writable: false`,`set`/`unset` 直接拒绝,而不是写下一个读取方永远看不到的变更。解析实时读取 `process.env`,绝不写回。 @@ -15,24 +15,33 @@ | 字段 | 默认值 | 含义 | |---|---|---| -| `path` | `/.env` | 凭据文档位置。 | +| `path` | `/.credentials.yaml` | 凭据文档位置。 | | `dshHome` | `$DSH_HOME` 或 `~/.dsh` | `path` 缺省时使用的 harness home。 | | `watch` | `true` | 热发布外部编辑。 | | `debounceMs` | `100` | watcher 写入稳定窗口。 | ## 文档本身 -dotenv 格式,用 `dotenv` 解析;写回用物理行级编辑器,保留一切不属于本次编辑的字节:`set` 原位改写该键的第一条赋值行、沿用该行自身的行尾(丢弃后续重复行——dotenv 按最后一条生效,重复行会反过来覆盖这次编辑),`unset` 只删除所属行,注释、无关行、CRLF 行尾,以及另一个键的引号多行值的续行,都逐字保留。每次写入都先在 [`dsh-atomic-write`](../../util/atomic-write/README.md) 的跨进程写锁下重读文档、把此前未观察到的一切发布出去,再在仅属主可访问(`0700`)的目录下以 `0600` 权限原子提交——因此并发写入者、或落在 watcher 防抖窗口内的外部编辑会被并入,而不是被覆盖。 +一个从凭据引用到值的 YAML mapping,除此之外别无他物: -值按 dotenv 能逐字读回的最窄样式渲染——裸值,其次单引号(完全字面),再次双引号(仅限无反斜杠,双引号读取会展开转义)。任何样式都无法表示的值,以及已经跨越多个物理行的条目,都会响亮失败而不是被静默破坏。空的存储值等于不存在(seam 规则)。 +```yaml +DEEPSEEK_API_KEY: sk-… +OPENAI_API_KEY: sk-… +``` + +该文档只存放凭据,因此任何偏离都是拒绝,而不是跳过某个条目——被静默忽略的键读起来就是「我存进去的密钥没有生效」。非 mapping 的根、非 POSIX 标识符的键、非字符串值、空字符串、重复键以及格式错误的 YAML 全部失败:启动时响亮失败,运行期热重载则告警并保留最后可用快照。没有 `version` 字段,也没有包装层;格式就是这个 mapping。 + +写入是对已解析文档打补丁而不是重建,因此注释与所有未触及条目的排版都会保留。直接位于某条目上方的注释属于该条目的注解,会随它一起删除。每次写入都先在 [`dsh-atomic-write`](../../util/atomic-write/README.md) 的跨进程写锁下重读文档、把此前未观察到的一切发布出去,再在仅属主可访问(`0700`)的目录下以 `0600` 权限原子提交——因此并发写入者、或落在 watcher 防抖窗口内的外部编辑会被并入,而不是被覆盖。磁盘上已经无法解析的文档会让写入失败,而不是覆盖 provider 读不懂的内容。 + +任何字符串值都能往返,包括多行值,因此不会再有条目因为缺少可用引号样式而不可写。空的存储值等于不存在(seam 规则)——这也正是文档中的空字符串被直接拒绝的原因:`unset` 删除键,而不是把它置空。 ## 热重载 -外部编辑在快照**整体替换**后按变更引用逐个发布 `credentials/updated`——磁盘上删掉的条目绝不在内存滞留。provider 自己的写入按内容识别,只发布属于该次提交的一个事件。运行期文档不可读时保留最后可用快照并告警;文件不存在即空存储;启动时不可读则响亮失败。非 POSIX 标识符的键属于被保留的文件内容,seam 无法寻址。 +外部编辑在快照**整体替换**后按变更引用逐个发布 `credentials/updated`——磁盘上删掉的条目绝不在内存滞留。provider 自己的写入按内容识别,只发布属于该次提交的一个事件。运行期文档不可读或无效时保留最后可用快照并告警;文件不存在即空存储;启动时不可读或无效则响亮失败。 ## 安全边界 -文档在 `0700` 目录下以 `0600` 权限存放,这挡得住其他 OS 用户,**挡不住**模型。工具进程(bash、文件系统工具)以同一用户身份运行,而已交付的 `workspace-write` 文件策略限制的是修改而非读取,因此它们读这个文件与读该用户拥有的任何其他文件毫无二致;也没有任何沙箱模式会把它单独挑出来。harness 真正守住的更窄:它绝不把该文档的解析后路径交给模型,也绝不把它载入进程环境(见 [app-boot 的个人配置](../../ui/app-boot/README.md#personal-config)),因此要拿到这个值,需要刻意去读一条并未交给 agent 的路径。 +文档在 `0700` 目录下以 `0600` 权限存放,这挡得住其他 OS 用户,**挡不住**模型。工具进程(bash、文件系统工具)以同一用户身份运行,而已交付的 `workspace-write` 文件策略限制的是修改而非读取,因此它们读这个文件与读该用户拥有的任何其他文件毫无二致;也没有任何沙箱模式会把它单独挑出来。harness 真正守住的更窄:它绝不把该文档的解析后路径交给模型,也绝不把它载入进程环境——这与用户的普通环境层 `$DSH_HOME/.env` 不同(见 [app-boot 的个人配置](../../ui/app-boot/README.md#personal-config))——因此要拿到这个值,需要刻意去读一条并未交给 agent 的路径。 这是审慎,不是边界。必须让提供方密钥远离自身 agent 的部署无法靠文件权限做到;OS 钥匙串 provider——一个模型的进程根本读不到的存储——才是延后的答案,它应当作为平级包与本 provider 并列。 diff --git a/packages/credentials/credentials-local/package.json b/packages/credentials/credentials-local/package.json index 0b8924d7f2..644904676a 100644 --- a/packages/credentials/credentials-local/package.json +++ b/packages/credentials/credentials-local/package.json @@ -35,8 +35,8 @@ }, "dependencies": { "chokidar": "^4.0.3", - "dotenv": "^17.2.0", - "schemastery": "^3.18.0" + "schemastery": "^3.18.0", + "yaml": "^2.9.0" }, "devDependencies": { "@deepseek-ai/dsh-atomic-write": "workspace:^", diff --git a/packages/credentials/credentials-local/src/index.ts b/packages/credentials/credentials-local/src/index.ts index c11c2db20c..bc1214d11b 100644 --- a/packages/credentials/credentials-local/src/index.ts +++ b/packages/credentials/credentials-local/src/index.ts @@ -1,13 +1,19 @@ /** * 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. + * a `$DSH_HOME/.credentials.yaml` 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 patching only its own key — comments and + * the formatting of every untouched entry survive — external edits + * hot-publish through the seam, and each reload replaces the snapshot + * wholesale so a deleted entry never lingers in memory. + * + * The document holds nothing but credentials, which is why it is a strict + * `CredentialRef`-to-string mapping rather than a dotenv file: a store the + * Harness owns and never materializes into the environment cannot also serve + * as the user's environment layer, and conflating the two is what made a + * non-secret in the old `$DSH_HOME/.env` silently unreachable. * @module @deepseek-ai/dsh-credentials-local */ @@ -16,15 +22,18 @@ 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 { Document, parseDocument } from 'yaml' 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' +/** Basename of the credentials document inside the harness home. */ +export const CREDENTIALS_FILENAME = '.credentials.yaml' + /** Plugin config: file location and hot-reload behavior. */ export interface Config { - /** Credentials document path; defaults to `.env` under the harness home. */ + /** Credentials document path; defaults to `.credentials.yaml` under the harness home. */ path?: string /** Harness home used when `path` is omitted; defaults to `$DSH_HOME` or `~/.dsh`. */ dshHome?: string @@ -43,13 +52,13 @@ interface ResolvedSpec { /** * Resolve the runtime spec from plugin config: an explicit `path` wins, - * otherwise the document lives at `/.env`. + * otherwise the document lives at `/.credentials.yaml`. * @param config - raw plugin config. * @returns the resolved file location and watch behavior. */ export function resolveSpec(config: Config): ResolvedSpec { return { - filename: resolve(config.path ?? join(resolveDshHome(config.dshHome), '.env')), + filename: resolve(config.path ?? join(resolveDshHome(config.dshHome), CREDENTIALS_FILENAME)), watch: config.watch ?? true, debounceMs: config.debounceMs ?? 100, } @@ -60,129 +69,64 @@ 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 +/** + * Parse one credentials document into its entries. The document is a strict + * mapping of {@link CredentialRef} to non-empty string: a non-mapping root, a + * key that is not a POSIX identifier, a non-string value, and an empty string + * are all rejected rather than skipped, because this file holds nothing but + * credentials and a silently ignored entry reads as "the key I stored has no + * effect". Duplicate keys surface as parser errors. An empty document is an + * empty store. + * @param text - the document's text. + * @param filename - absolute path, quoted in errors. + * @returns the parsed entries, keyed by reference. + */ +export function parseCredentialsDocument(text: string, filename: string): Map { + const document = parseDocument(text, { prettyErrors: true, uniqueKeys: true }) + if (document.errors.length > 0) { + throw new Error(`credentials-local: invalid document at ${filename}: ${ + document.errors.map(error => error.message).join('; ')}`) } - return false + const root: unknown = document.toJS() ?? {} + if (typeof root !== 'object' || root === null || Array.isArray(root)) { + throw new TypeError(`credentials-local: ${filename} must be a mapping of credential reference to value`) + } + const entries = new Map() + for (const [key, value] of Object.entries(root as Record)) { + // credentialRef throws on anything that is not a POSIX identifier, which + // is exactly the constraint a stored reference must satisfy to be + // addressable through the seam. + credentialRef(key) + if (typeof value !== 'string') { + throw new TypeError(`credentials-local: the value for "${key}" in ${filename} must be a string`) + } + if (value.length === 0) { + throw new Error(`credentials-local: the value for "${key}" in ${filename} is empty; remove the key instead`) + } + entries.set(key, value) + } + return entries } /** - * 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. + * Render the next document text with one reference set or deleted. Editing + * the parsed document rather than rebuilding it keeps comments and the + * formatting of every untouched entry; an absent document starts a fresh one. + * @param text - the current document text, `undefined` while the file is absent. + * @param ref - the reference to write. + * @param value - the new value, or `undefined` to delete the key. + * @returns the text to persist. */ -function 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`) +function renderDocument(text: string | undefined, ref: CredentialRef, value: string | undefined): string { + // `text` only ever caches content that parsed successfully, so this re-parse + // for the mutable comment-preserving tree cannot fail. + const document = text === undefined ? new Document({}) : parseDocument(text) + if (value === undefined) document.deleteIn([ref]) + else document.setIn([ref], value) + return document.toString() } -/** 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`). */ +/** File-backed credentials provider (`$DSH_HOME/.credentials.yaml`). */ export class CredentialsLocal extends Credentials { /* jscpd:ignore-start -- deliberate config-surface and lifecycle symmetry with settings-local (prefer symmetry for parallel values); extracting the shared @@ -273,7 +217,7 @@ export class CredentialsLocal extends Credentials { 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' }) + if (stored !== undefined) return Promise.resolve({ value: stored, source: 'file' }) return Promise.resolve(undefined) } @@ -283,11 +227,7 @@ export class CredentialsLocal extends Credentials { return Promise.resolve({ configured: true, source: 'env', writable: false }) } const stored = this.values.get(ref) - if (stored !== undefined && stored.length > 0) { - // A quoted multi-line value resolves fine but the line editor refuses to - // rewrite it, so writability must say what set() would actually do. - return Promise.resolve({ configured: true, source: 'file', writable: !stored.includes('\n') }) - } + if (stored !== undefined) return Promise.resolve({ configured: true, source: 'file', writable: true }) return Promise.resolve({ configured: false, writable: true }) } @@ -350,12 +290,7 @@ export class CredentialsLocal extends Credentials { await this.reconcileFromDisk() const existing = this.values.get(ref) if (value === undefined && existing === undefined) return - if (existing !== undefined && existing.includes('\n')) { - throw new Error( - `credentials-local: "${ref}" is a multi-line entry this line editor would corrupt; edit ${this.spec.filename} directly`, - ) - } - const nextText = upsertLine(this.text, ref, value === undefined ? undefined : renderLine(ref, value)) + const nextText = renderDocument(this.text, ref, value) // 0600: a document holding secrets is never world-readable. await writeFileAtomic(this.spec.filename, nextText, { mode: 0o600, dirMode: 0o700 }) this.text = nextText @@ -374,12 +309,16 @@ export class CredentialsLocal extends Credentials { 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', + + ' shadowed; unset it in the launching environment (or in a loaded .env) instead', ) } } - /** Boot read: an absent file is an empty store; any other failure is loud. */ + /** + * Boot read: an absent file is an empty store; an invalid one fails the + * plugin's activation, because a credentials document that exists but + * cannot be trusted must never be treated as "no credentials stored". + */ private async loadInitial(): Promise { let text: string try { @@ -388,8 +327,8 @@ export class CredentialsLocal extends Credentials { if (!isENOENT(error)) throw error return } + this.values = parseCredentialsDocument(text, this.spec.filename) this.text = text - this.values = new Map(Object.entries(parse(text))) } /* jscpd:ignore-start -- same deliberate mirror of settings-local's reload and @@ -415,10 +354,10 @@ export class CredentialsLocal extends Credentials { /** * Compare the on-disk text against the cache and publish any difference - * into the seam. Absence publishes the empty store; an unreadable file - * throws, so each caller picks its policy — a reload warns and keeps the - * last good snapshot, a write fails loud. dotenv parsing is lenient by - * design and cannot fail. + * into the seam. Absence publishes the empty store; an unreadable or + * invalid document throws, so each caller picks its policy — a reload warns + * and keeps the last good snapshot, a write fails loud rather than + * overwriting a document it could not understand. */ private async reconcileFromDisk(): Promise { let text: string | undefined @@ -429,7 +368,7 @@ export class CredentialsLocal extends Credentials { text = undefined } if (text === this.text || this.isClosed()) return - const next = text === undefined ? new Map() : new Map(Object.entries(parse(text))) + const next = text === undefined ? new Map() : parseCredentialsDocument(text, this.spec.filename) const changed = this.changedRefs(this.values, next) this.text = text this.values = next @@ -437,21 +376,12 @@ export class CredentialsLocal extends Credentials { } /* jscpd:ignore-end */ - /** Seam-addressable entries whose effective (non-empty) value changed. */ + /** Entries whose stored value changed; the parser has already proven every key addressable. */ private changedRefs(prev: Map, 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. - } + if (prev.get(key) === next.get(key)) continue + changed.push(credentialRef(key)) } return changed } diff --git a/packages/credentials/credentials-local/tests/drain.spec.ts b/packages/credentials/credentials-local/tests/drain.spec.ts index baefbd52c5..9cf4e600fb 100644 --- a/packages/credentials/credentials-local/tests/drain.spec.ts +++ b/packages/credentials/credentials-local/tests/drain.spec.ts @@ -42,7 +42,7 @@ describe('write-drain teardown', () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-credentials-drain-')) cleanups.push(() => rm(dir, { recursive: true, force: true })) const ctx = new Context() - const fiber = ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false }) + const fiber = ctx.plugin(CredentialsLocal, { path: join(dir, '.credentials.yaml'), watch: false }) await fiber const service = ctx.credentials diff --git a/packages/credentials/credentials-local/tests/local.spec.ts b/packages/credentials/credentials-local/tests/local.spec.ts index 4ebaed1a0c..d5ffddc54d 100644 --- a/packages/credentials/credentials-local/tests/local.spec.ts +++ b/packages/credentials/credentials-local/tests/local.spec.ts @@ -42,29 +42,29 @@ function updates(ctx: Context): CredentialRef[] { } describe('resolveSpec', () => { - it('defaults to .env under the harness home with watching on', () => { + it('defaults to .credentials.yaml under the harness home with watching on', () => { const spec = resolveSpec({ dshHome: '/custom/home' }) - expect(spec).toEqual({ filename: resolve('/custom/home/.env'), watch: true, debounceMs: 100 }) + expect(spec).toEqual({ filename: resolve('/custom/home/.credentials.yaml'), watch: true, debounceMs: 100 }) }) it('lets an explicit path win over the home', () => { - const spec = resolveSpec({ path: '/etc/dsh/creds.env', dshHome: '/ignored', watch: false, debounceMs: 5 }) - expect(spec).toEqual({ filename: resolve('/etc/dsh/creds.env'), watch: false, debounceMs: 5 }) + const spec = resolveSpec({ path: '/etc/dsh/creds.yaml', dshHome: '/ignored', watch: false, debounceMs: 5 }) + expect(spec).toEqual({ filename: resolve('/etc/dsh/creds.yaml'), watch: false, debounceMs: 5 }) }) }) describe('layering and reads', () => { it('treats an absent file as an empty writable store', async () => { const dir = await tempDir() - const ctx = await boot({ path: join(dir, '.env'), watch: false }) + const ctx = await boot({ path: join(dir, '.credentials.yaml'), watch: false }) expect(await ctx.credentials.resolve(KEY)).toBeUndefined() expect(await ctx.credentials.describe(KEY)).toEqual({ configured: false, writable: true }) }) - it('serves file entries, including export-prefixed and quoted values', async () => { + it('serves file entries alongside comments and quoted values', async () => { const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, '# notes\nexport DSH_CRED_TEST=plain\nDSH_CRED_OTHER="with space"\n') + const path = join(dir, '.credentials.yaml') + await writeFile(path, '# notes\nDSH_CRED_TEST: plain\nDSH_CRED_OTHER: "with space"\n') const ctx = await boot({ path, watch: false }) expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'plain', source: 'file' }) expect(await ctx.credentials.resolve(OTHER)).toEqual({ value: 'with space', source: 'file' }) @@ -73,22 +73,22 @@ describe('layering and reads', () => { it('lets a non-empty process environment win read-only over the file', async () => { const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, 'DSH_CRED_TEST=from-file\n') + const path = join(dir, '.credentials.yaml') + await 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 () => { + it('treats an empty environment value as absent, falling through to the file', async () => { const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, 'DSH_CRED_TEST=\n') + const path = join(dir, '.credentials.yaml') + await writeFile(path, 'DSH_CRED_TEST: stored\n') const ctx = await boot({ path, watch: false }) vi.stubEnv('DSH_CRED_TEST', '') - expect(await ctx.credentials.resolve(KEY)).toBeUndefined() - expect(await ctx.credentials.describe(KEY)).toEqual({ configured: false, writable: true }) + expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'stored', source: 'file' }) + expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'file', writable: true }) }) it('fails boot loud when the document exists but cannot be read', async () => { @@ -100,110 +100,149 @@ describe('layering and reads', () => { }) }) -describe('line-editing writes', () => { - it('appends a missing key to a fresh 0600 document and emits the commit', async () => { +describe('document validation', () => { + // Every rejection below is a boot failure rather than a skipped entry: this + // document holds nothing but credentials, so an ignored key would read as + // "the secret I stored has no effect". + it.each([ + ['a non-mapping root', 'just a string\n', /must be a mapping/], + ['a sequence root', '- DSH_CRED_TEST\n', /must be a mapping/], + ['a key that is not a POSIX identifier', 'not-a-ref: value\n', /credential ref/], + ['a non-string value', 'DSH_CRED_TEST: 123\n', /must be a string/], + ['an empty value', 'DSH_CRED_TEST: ""\n', /is empty/], + ['duplicate keys', 'DSH_CRED_TEST: one\nDSH_CRED_TEST: two\n', /invalid document/], + ['malformed yaml', 'DSH_CRED_TEST: "unterminated\n', /invalid document/], + ])('fails boot on %s', async (_case, text, message) => { const dir = await tempDir() - const path = join(dir, '.env') + const path = join(dir, '.credentials.yaml') + await writeFile(path, text) + const ctx = new Context() + await expect(ctx.plugin(CredentialsLocal, { path, watch: false })).rejects.toThrow(message) + }) + + it('reads an empty document as an empty store', async () => { + const dir = await tempDir() + const path = join(dir, '.credentials.yaml') + await writeFile(path, '# nothing stored yet\n') + const ctx = await boot({ path, watch: false }) + expect(await ctx.credentials.resolve(KEY)).toBeUndefined() + }) +}) + +describe('document writes', () => { + it('adds a missing key to a fresh 0600 document and emits the commit', async () => { + const dir = await tempDir() + const path = join(dir, '.credentials.yaml') const ctx = await boot({ path, watch: false }) const seen = updates(ctx) await ctx.credentials.set(KEY, 'sk-fresh') - expect(await readFile(path, 'utf8')).toBe('DSH_CRED_TEST=sk-fresh\n') + expect(await readFile(path, 'utf8')).toBe('DSH_CRED_TEST: sk-fresh\n') expect((await stat(path)).mode & 0o777).toBe(0o600) expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'sk-fresh', source: 'file' }) expect(seen).toEqual([KEY]) }) - it('rewrites one line in place, preserving every other byte and dropping duplicates', async () => { + it('patches one entry, preserving comments and every untouched entry', async () => { const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, '# deployment notes\nFIRST=one\n\nDSH_CRED_TEST=old\nTRAILING=x\nDSH_CRED_TEST=older') + const path = join(dir, '.credentials.yaml') + await writeFile(path, '# deployment notes\nDSH_CRED_OTHER: keep\n\n# the one under edit\nDSH_CRED_TEST: old\n') const ctx = await boot({ path, watch: false }) await ctx.credentials.set(KEY, 'new value!') - expect(await readFile(path, 'utf8')).toBe('# deployment notes\nFIRST=one\n\nDSH_CRED_TEST=\'new value!\'\nTRAILING=x\n') + expect(await readFile(path, 'utf8')).toBe( + '# deployment notes\nDSH_CRED_OTHER: keep\n\n# the one under edit\nDSH_CRED_TEST: new value!\n', + ) }) - it('quotes hostile values so they round-trip through a fresh provider', async () => { + it('round-trips values no dotenv line could represent', async () => { const dir = await tempDir() - const path = join(dir, '.env') + const path = join(dir, '.credentials.yaml') const ctx = await boot({ path, watch: false }) - const singleQuoted = 'with "quote", back\\slash and space' - const doubleQuoted = "it's got an apostrophe" - await ctx.credentials.set(KEY, singleQuoted) - await ctx.credentials.set(OTHER, doubleQuoted) + const multiLine = 'line one\nline two' + const mixedQuotes = 'both \' and "' + await ctx.credentials.set(KEY, multiLine) + await ctx.credentials.set(OTHER, mixedQuotes) const reread = await boot({ path, watch: false }) - expect(await reread.credentials.resolve(KEY)).toEqual({ value: singleQuoted, source: 'file' }) - expect(await reread.credentials.resolve(OTHER)).toEqual({ value: doubleQuoted, source: 'file' }) + expect(await reread.credentials.resolve(KEY)).toEqual({ value: multiLine, source: 'file' }) + expect(await reread.credentials.resolve(OTHER)).toEqual({ value: mixedQuotes, source: 'file' }) + expect(await reread.credentials.describe(KEY)).toEqual({ configured: true, source: 'file', writable: true }) }) - it('fails loud on values no .env quoting style reads back verbatim', async () => { + it('unsets only the owning entry, with its own annotation, and keeps an absent unset silent', async () => { const dir = await tempDir() - const ctx = await boot({ path: join(dir, '.env'), watch: false }) - await expect(ctx.credentials.set(KEY, 'line one\nline two')).rejects.toThrow(/control characters/) - await expect(ctx.credentials.set(KEY, 'both \' and "')).rejects.toThrow(/mixes quoting/) - }) - - it('unsets only the owning line and keeps an absent unset silent', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, '# keep\nDSH_CRED_TEST=gone\nDSH_CRED_OTHER=stays\n') + const path = join(dir, '.credentials.yaml') + // Comments above an entry are that entry's annotation and go with it when + // it is removed — including anything above the document's first entry. + // Every other entry keeps its own comments. + await writeFile(path, '# about the doomed one\nDSH_CRED_TEST: gone\n# about the survivor\nDSH_CRED_OTHER: stays\n') const ctx = await boot({ path, watch: false }) const seen = updates(ctx) await ctx.credentials.unset(KEY) - expect(await readFile(path, 'utf8')).toBe('# keep\nDSH_CRED_OTHER=stays\n') + expect(await readFile(path, 'utf8')).toBe('# about the survivor\nDSH_CRED_OTHER: stays\n') await ctx.credentials.unset(KEY) expect(seen).toEqual([KEY]) }) - it('rejects empty values, shadowed writes, and multi-line entries', async () => { + it('rejects empty values and writes the environment would shadow', async () => { const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, 'DSH_CRED_TEST="line one\nline two"\n') + const path = join(dir, '.credentials.yaml') + await writeFile(path, 'DSH_CRED_TEST: stored\n') const ctx = await boot({ path, watch: false }) await expect(ctx.credentials.set(KEY, '')).rejects.toThrow(/empty value/) - await expect(ctx.credentials.set(KEY, 'next')).rejects.toThrow(/multi-line/) - await expect(ctx.credentials.unset(KEY)).rejects.toThrow(/multi-line/) vi.stubEnv('DSH_CRED_TEST', 'shadowing') await expect(ctx.credentials.set(KEY, 'next')).rejects.toThrow(/shadowed/) await expect(ctx.credentials.unset(KEY)).rejects.toThrow(/shadowed/) }) - it('leaves an empty document after unsetting the only entry', async () => { + it('leaves an empty mapping after unsetting the only entry', async () => { const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, 'DSH_CRED_TEST=only\n') + const path = join(dir, '.credentials.yaml') + await 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('') + expect(await readFile(path, 'utf8')).toBe('{}\n') + // The emptied document still reloads as an empty store, not a parse error. + const reread = await boot({ path, watch: false }) + expect(await reread.credentials.resolve(KEY)).toBeUndefined() + }) + + it('fails a write loud when the on-disk document became invalid', async () => { + const dir = await tempDir() + const path = join(dir, '.credentials.yaml') + const ctx = await boot({ path, watch: false }) + // An external editor left the document unparsable: the read-modify-write + // must refuse rather than overwrite content it cannot understand. + await writeFile(path, 'DSH_CRED_TEST: "unterminated\n') + await expect(ctx.credentials.set(OTHER, 'lands')).rejects.toThrow(/invalid document/) }) it('chains past a rejected write so one bad value cannot poison the queue', async () => { const dir = await tempDir() - const path = join(dir, '.env') + const path = join(dir, '.credentials.yaml') const ctx = await boot({ path, watch: false }) - const bad = expect(ctx.credentials.set(KEY, 'both \' and "')).rejects.toThrow(/mixes quoting/) + const bad = expect(ctx.credentials.set(KEY, '')).rejects.toThrow(/empty value/) const good = ctx.credentials.set(OTHER, 'lands') await bad await good - expect(await readFile(path, 'utf8')).toBe('DSH_CRED_OTHER=lands\n') + expect(await readFile(path, 'utf8')).toBe('DSH_CRED_OTHER: lands\n') }) it('serializes concurrent writes so both land in the one document', async () => { const dir = await tempDir() - const path = join(dir, '.env') + const path = join(dir, '.credentials.yaml') const ctx = await boot({ path, watch: false }) await Promise.all([ ctx.credentials.set(KEY, 'one'), ctx.credentials.set(OTHER, 'two'), ]) - expect(await readFile(path, 'utf8')).toBe('DSH_CRED_TEST=one\nDSH_CRED_OTHER=two\n') + expect(await readFile(path, 'utf8')).toBe('DSH_CRED_TEST: one\nDSH_CRED_OTHER: two\n') }) it('refuses writes after disposal', async () => { const dir = await tempDir() const ctx = new Context() - const fiber = ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false }) + const fiber = ctx.plugin(CredentialsLocal, { path: join(dir, '.credentials.yaml'), watch: false }) await fiber // Capture the handle first: disposal also removes the ctx.credentials service. const service = ctx.credentials @@ -215,20 +254,20 @@ describe('line-editing writes', () => { describe('real hot reload', () => { it('publishes external edits, replaces the snapshot wholesale, and suppresses self-writes', async () => { const dir = await tempDir() - const path = join(dir, '.env') + const path = join(dir, '.credentials.yaml') // Watching starts on an existing document: creation racing watcher setup // is a chokidar readiness gap, not the reload contract under test. - await writeFile(path, 'DSH_CRED_TEST=boot\n') + await 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 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 writeFile(path, 'DSH_CRED_TEST: live\n') await vi.waitFor(async () => { expect(await ctx.credentials.resolve(OTHER)).toBeUndefined() }) diff --git a/packages/credentials/credentials-local/tests/review-fixes.spec.ts b/packages/credentials/credentials-local/tests/review-fixes.spec.ts index 78e51c90e1..7d2f447e5a 100644 --- a/packages/credentials/credentials-local/tests/review-fixes.spec.ts +++ b/packages/credentials/credentials-local/tests/review-fixes.spec.ts @@ -1,7 +1,7 @@ // Third-review behaviors: read-modify-write under the writer lock (external // edits survive an API write), the contained credentials/updated fan-out (a -// broken observer never fails a committed write), and the physical-line -// editor's multi-line and CRLF discipline. +// broken observer never fails a committed write), and the YAML document +// editor's isolation between entries. import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' @@ -37,18 +37,18 @@ async function boot(config: ConstructorParameters[1]): describe('read-modify-write', () => { it('folds an unobserved external edit into a write instead of overwriting it', async () => { const dir = await tempDir() - const path = join(dir, '.env') + const path = join(dir, '.credentials.yaml') const ctx = await boot({ path, watch: false }) const seen: string[] = [] ctx.on('credentials/updated', (ref) => { seen.push(ref) }) await ctx.credentials.set(ALPHA, 'one') // The external edit has landed on disk but no watcher reported it (watch // is off — the same blind spot as a debounce window or a missed event). - await writeFile(path, `${ALPHA}=one\n${BETA}=external\n`) + await 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`) + expect(text).toContain(`${BETA}: external`) + expect(text).toContain(`${ALPHA}: two`) // The fold published the unobserved entry before the write's own commit. expect(seen).toEqual([ALPHA, BETA, ALPHA]) expect(await ctx.credentials.resolve(BETA)).toEqual({ value: 'external', source: 'file' }) @@ -56,7 +56,7 @@ describe('read-modify-write', () => { it('keeps both refs when two providers write the same document concurrently', async () => { const dir = await tempDir() - const path = join(dir, '.env') + const path = join(dir, '.credentials.yaml') const first = await boot({ path, watch: false }) const second = await boot({ path, watch: false }) await Promise.all([ @@ -71,7 +71,7 @@ describe('read-modify-write', () => { it('creates the credentials directory owner-only', async () => { const dir = await tempDir() const home = join(dir, 'home') - const ctx = await boot({ path: join(home, '.env'), watch: false }) + const ctx = await boot({ path: join(home, '.credentials.yaml'), watch: false }) await ctx.credentials.set(ALPHA, 'one') expect((await stat(home)).mode & 0o777).toBe(0o700) }) @@ -80,7 +80,7 @@ describe('read-modify-write', () => { describe('contained update fan-out', () => { it('does not fail a committed set when a listener throws, and later listeners still run', async () => { const dir = await tempDir() - const ctx = await boot({ path: join(dir, '.env'), watch: false }) + const ctx = await boot({ path: join(dir, '.credentials.yaml'), watch: false }) ctx.on('credentials/updated', () => { throw new Error('observer boom') }) @@ -93,7 +93,7 @@ describe('contained update fan-out', () => { it('contains an async listener rejection', async () => { const dir = await tempDir() - const ctx = await boot({ path: join(dir, '.env'), watch: false }) + const ctx = await boot({ path: join(dir, '.credentials.yaml'), watch: false }) // An unknown-returning function keeps the typed surface legal while the // runtime value is still the rejected promise the containment must handle. const boom = (): unknown => Promise.reject(new Error('async observer boom')) @@ -104,7 +104,7 @@ describe('contained update fan-out', () => { it('rethrows an invariant-coded failure after the commit and the remaining listeners', async () => { const dir = await tempDir() - const path = join(dir, '.env') + const path = join(dir, '.credentials.yaml') const ctx = await boot({ path, watch: false }) ctx.on('credentials/updated', () => { throw Object.assign(new Error('forged relation'), { code: 'INVARIANT' }) @@ -114,78 +114,33 @@ describe('contained update fan-out', () => { await expect(ctx.credentials.set(ALPHA, 'one')).rejects.toThrow(/forged relation/) // Harness-fatal by design — but the write itself committed first. expect(second).toHaveBeenCalledWith(ALPHA) - expect(await readFile(path, 'utf8')).toContain(`${ALPHA}=one`) + expect(await readFile(path, 'utf8')).toContain(`${ALPHA}: one`) expect(await ctx.credentials.resolve(ALPHA)).toEqual({ value: 'one', source: 'file' }) }) }) -describe('physical-line editor', () => { - it('never mistakes a quoted multi-line continuation for an assignment', async () => { +describe('document editor', () => { + it('leaves a sibling multi-line value untouched while patching one entry', async () => { const dir = await tempDir() - const path = join(dir, '.env') - const wrapped = `DSH_REVIEW_WRAPPED="line1\n${INNER}=looks-like-one\nline3"\n${ALPHA}=a\n` + const path = join(dir, '.credentials.yaml') + const wrapped = `DSH_REVIEW_WRAPPED: |-\n line1\n line2\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' }) + expect(await readFile(path, 'utf8')).toBe(`DSH_REVIEW_WRAPPED: |-\n line1\n line2\n${ALPHA}: b\n`) + expect(await ctx.credentials.resolve(credentialRef('DSH_REVIEW_WRAPPED'))) + .toEqual({ value: 'line1\nline2', source: 'file' }) }) - it('preserves CRLF line endings on untouched and edited lines', async () => { + it('stores a value that looks like another entry without creating one', async () => { const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, `# note\r\n${ALPHA}=a\r\n${BETA}=keep\r\n`) + const path = join(dir, '.credentials.yaml') const ctx = await boot({ path, watch: false }) - await ctx.credentials.set(ALPHA, 'b') - expect(await readFile(path, 'utf8')).toBe(`# note\r\n${ALPHA}=b\r\n${BETA}=keep\r\n`) - await ctx.credentials.set(INNER, 'new') - expect(await readFile(path, 'utf8')).toBe(`# note\r\n${ALPHA}=b\r\n${BETA}=keep\r\n${INNER}=new\r\n`) - }) - - it('terminates a final unterminated line before appending', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, `${ALPHA}=a`) - const ctx = await boot({ path, watch: false }) - await ctx.credentials.set(BETA, 'b') - expect(await readFile(path, 'utf8')).toBe(`${ALPHA}=a\n${BETA}=b\n`) - }) - - it('rewrites a final unterminated assignment in the dominant ending style', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, `${ALPHA}=a`) - const ctx = await boot({ path, watch: false }) - await ctx.credentials.set(ALPHA, 'b') - expect(await readFile(path, 'utf8')).toBe(`${ALPHA}=b\n`) - }) - - it('tracks a single-quoted multi-line value through its continuation', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, `DSH_REVIEW_SQ='line1\n${INNER}=shadow\nline3'\n`) - const ctx = await boot({ path, watch: false }) - await ctx.credentials.set(ALPHA, 'x') - expect(await readFile(path, 'utf8')) - .toBe(`DSH_REVIEW_SQ='line1\n${INNER}=shadow\nline3'\n${ALPHA}=x\n`) - }) - - it('reports a multi-line entry as unwritable and refuses to edit it', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, `${ALPHA}="line1\nline2"\n`) - const ctx = await boot({ path, watch: false }) - expect(await ctx.credentials.describe(ALPHA)).toEqual({ configured: true, source: 'file', writable: false }) - await expect(ctx.credentials.set(ALPHA, 'flat')).rejects.toThrow(/multi-line entry/) - await expect(ctx.credentials.unset(ALPHA)).rejects.toThrow(/multi-line entry/) - // Resolution still serves the multi-line value. - expect(await ctx.credentials.resolve(ALPHA)).toEqual({ value: 'line1\nline2', source: 'file' }) + // The stored text must stay a value: a quoted-scalar write that leaked its + // own structure would silently mint a credential nobody stored. + await ctx.credentials.set(ALPHA, `${INNER}: injected`) + const reread = await boot({ path, watch: false }) + expect(await reread.credentials.resolve(ALPHA)).toEqual({ value: `${INNER}: injected`, source: 'file' }) + expect(await reread.credentials.resolve(INNER)).toBeUndefined() }) }) diff --git a/packages/credentials/credentials-local/tests/watcher.spec.ts b/packages/credentials/credentials-local/tests/watcher.spec.ts index 6ff53252cf..8f34b09868 100644 --- a/packages/credentials/credentials-local/tests/watcher.spec.ts +++ b/packages/credentials/credentials-local/tests/watcher.spec.ts @@ -66,21 +66,21 @@ async function boot(config: ConstructorParameters[1]): describe('watcher pipeline', () => { it('clamps the write-settle poll interval for a zero debounce', async () => { const dir = await tempDir() - await boot({ path: join(dir, '.env'), debounceMs: 0 }) + await boot({ path: join(dir, '.credentials.yaml'), debounceMs: 0 }) const [instance] = await fakeInstances() expect(instance!.options.awaitWriteFinish).toEqual({ stabilityThreshold: 0, pollInterval: 1 }) }) it('survives a watcher error and keeps publishing later edits', async () => { const dir = await tempDir() - const path = join(dir, '.env') + const path = join(dir, '.credentials.yaml') const ctx = await boot({ path, debounceMs: 5 }) const [instance] = await fakeInstances() instance!.watcher.emit('error', new Error('watch backend failure')) expect(await ctx.credentials.resolve(KEY)).toBeUndefined() - await writeFile(path, 'DSH_CRED_PIPE=arrived\n') + await 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' }) @@ -89,8 +89,8 @@ describe('watcher pipeline', () => { it('keeps the last good snapshot when the file turns unreadable at runtime', async () => { const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, 'DSH_CRED_PIPE=good\n') + const path = join(dir, '.credentials.yaml') + await writeFile(path, 'DSH_CRED_PIPE: good\n') const ctx = await boot({ path, debounceMs: 5 }) await chmod(path, 0o000) @@ -104,7 +104,7 @@ describe('watcher pipeline', () => { it('keeps the reload queue alive after an invariant violation escapes the fan-out', async () => { const dir = await tempDir() - const path = join(dir, '.env') + const path = join(dir, '.credentials.yaml') const ctx = await boot({ path, debounceMs: 5 }) let arm = true ctx.on('credentials/updated', () => { @@ -113,7 +113,7 @@ describe('watcher pipeline', () => { }) const [instance] = await fakeInstances() - await writeFile(path, 'DSH_CRED_PIPE=first\n') + 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. @@ -122,7 +122,7 @@ describe('watcher pipeline', () => { }) arm = false - await writeFile(path, 'DSH_CRED_PIPE=second\n') + 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' }) @@ -131,8 +131,8 @@ describe('watcher pipeline', () => { it('quiesces the refresh pipeline before dispose completes', async () => { const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, 'DSH_CRED_PIPE=initial\n') + const path = join(dir, '.credentials.yaml') + await writeFile(path, 'DSH_CRED_PIPE: initial\n') const ctx = new Context() const fiber = ctx.plugin(CredentialsLocal, { path, debounceMs: 5 }) await fiber @@ -142,7 +142,7 @@ describe('watcher pipeline', () => { if (disposed) postDisposeCommits += 1 }) - await writeFile(path, 'DSH_CRED_PIPE=changed\n') + 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. @@ -158,8 +158,8 @@ describe('watcher pipeline', () => { it('empties the snapshot when the document is deleted and emits the removals', async () => { const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, 'DSH_CRED_PIPE=doomed\n') + const path = join(dir, '.credentials.yaml') + await writeFile(path, 'DSH_CRED_PIPE: doomed\n') const ctx = await boot({ path, debounceMs: 5 }) const seen: string[] = [] ctx.on('credentials/updated', (ref) => { @@ -175,30 +175,39 @@ describe('watcher pipeline', () => { expect(seen).toEqual([KEY]) }) - it('publishes only seam-addressable keys and preserves the rest untouched', async () => { + it('keeps the last good snapshot when an external edit makes the document invalid', async () => { const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, 'BAD-KEY=1\nDSH_CRED_PIPE=a\n') + const path = join(dir, '.credentials.yaml') + await writeFile(path, 'DSH_CRED_PIPE: a\n') const ctx = await boot({ path, debounceMs: 5 }) const seen: string[] = [] ctx.on('credentials/updated', (ref) => { seen.push(ref) }) - await writeFile(path, 'BAD-KEY=2\nDSH_CRED_PIPE=b\n') + // A key the seam cannot address is a rejection, not preserved content: + // this document holds nothing but credentials. A live reload must warn + // and keep serving the last good snapshot rather than take the process + // down or silently drop the entry it could not validate. + await writeFile(path, 'BAD-KEY: 2\nDSH_CRED_PIPE: b\n') const [instance] = await fakeInstances() instance!.watcher.emit('all', 'change', path) + await new Promise(resolve => setTimeout(resolve, 50)) + expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'a', source: 'file' }) + expect(seen).toEqual([]) + + // Repairing the document resumes publishing. + await writeFile(path, 'DSH_CRED_PIPE: b\n') + instance!.watcher.emit('all', 'change', path) await vi.waitFor(async () => { expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'b', source: 'file' }) }) - // The dash-named key is preserved file content the seam cannot address: - // its change publishes nothing and breaks nothing. expect(seen).toEqual([KEY]) }) it('treats an event for a still-absent file as a no-op', async () => { const dir = await tempDir() - const path = join(dir, '.env') + const path = join(dir, '.credentials.yaml') const ctx = await boot({ path, debounceMs: 5 }) const [instance] = await fakeInstances() instance!.watcher.emit('all', 'add', path) @@ -208,12 +217,12 @@ describe('watcher pipeline', () => { it('reconciles at watcher ready so a change during setup is not missed', async () => { const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, `${KEY}=a\n`) + const path = join(dir, '.credentials.yaml') + await 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`) + await writeFile(path, `${KEY}: written-before-ready\n`) const [instance] = await fakeInstances() instance!.watcher.emit('ready') await vi.waitFor(async () => { diff --git a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts index 25cf4f293b..6aecdcdaf7 100644 --- a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts @@ -48,7 +48,7 @@ async function boot(dir: string, config: object): Promise { 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(CredentialsLocal, { path: join(dir, '.credentials.yaml'), watch: false }) await ctx.plugin(LlmDeepSeek, config) return { ctx, settingsFiber } } @@ -61,7 +61,7 @@ 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') + await writeFile(join(dir, '.credentials.yaml'), '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 }) @@ -81,7 +81,7 @@ describe('request-level dynamic configuration', () => { 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') + await writeFile(join(dir, '.credentials.yaml'), 'DEEPSEEK_API_KEY: file-key\n') const server = await mockServer([{ kind: 'sse', events: textEvents }]) const { ctx } = await boot(dir, { baseURL: server.url }) @@ -178,7 +178,7 @@ describe('request-level dynamic configuration', () => { 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') + await writeFile(join(dir, '.credentials.yaml'), '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 }) diff --git a/packages/llm/llm-deepseek/tests/loader-composition.spec.ts b/packages/llm/llm-deepseek/tests/loader-composition.spec.ts index 402f94441d..c8d596af74 100644 --- a/packages/llm/llm-deepseek/tests/loader-composition.spec.ts +++ b/packages/llm/llm-deepseek/tests/loader-composition.spec.ts @@ -2,7 +2,7 @@ * 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 + * settings.yaml and the credentials document 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. @@ -42,16 +42,16 @@ afterEach(async () => { async function loadComposition( options: { withDynamic: boolean; baseURL: string; reuseRoot?: string }, -): Promise<{ ctx: Context; settingsPath: string; envPath: string }> { +): Promise<{ ctx: Context; settingsPath: string; credentialsPath: 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') + const credentialsPath = join(root, '.credentials.yaml') if (options.withDynamic && fresh) { await writeFile(settingsPath, '# personal settings\n') - await writeFile(envPath, 'DEEPSEEK_API_KEY=boot-key\n') + await writeFile(credentialsPath, 'DEEPSEEK_API_KEY: boot-key\n') } const configPath = join(root, 'cordis.yml') @@ -68,7 +68,7 @@ async function loadComposition( '- id: credentials', " name: '@deepseek-ai/dsh-credentials-local'", ' config:', - ` path: ${JSON.stringify(envPath)}`, + ` path: ${JSON.stringify(credentialsPath)}`, ' debounceMs: 10', ] : [], @@ -103,15 +103,15 @@ async function loadComposition( config: { path: pathToFileURL(configPath).href }, }) await ctx.loader.await() - return { ctx, settingsPath, envPath } + return { ctx, settingsPath, credentialsPath } } describe('llm-deepseek real dynamic composition', () => { - it('boots from cordis.yml and routes the next request after external settings and .env edits', async () => { + it('boots from cordis.yml and routes the next request after external settings and credential 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 }) + const { ctx, settingsPath, credentialsPath } = 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: [] }) @@ -122,7 +122,7 @@ describe('llm-deepseek real dynamic composition', () => { 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 writeFile(credentialsPath, '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 }) @@ -134,7 +134,7 @@ describe('llm-deepseek real dynamic composition', () => { 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. + // the credentials document 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 }]) diff --git a/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts index d13234f8db..2c60ba0e83 100644 --- a/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts @@ -44,7 +44,7 @@ async function boot(dir: string, config: LlmPiAi.Config): Promise { }) 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(CredentialsLocal, { path: join(dir, '.credentials.yaml'), watch: false }) await ctx.plugin(LlmPiAi, config) return ctx } @@ -53,7 +53,7 @@ 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') + await writeFile(join(dir, '.credentials.yaml'), '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, {}) @@ -112,7 +112,7 @@ describe('request-level dynamic profiles', () => { 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') + await writeFile(join(dir, '.credentials.yaml'), '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 } }, diff --git a/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts b/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts index 460e78b7c2..5d32a748ea 100644 --- a/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts +++ b/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts @@ -3,7 +3,7 @@ * 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 + * carries the credential the credentials document supplies. A hand-mounted `ctx.plugin` cannot * catch Loader export-shape failures, which is why the twin adapter has the * same guard. */ @@ -40,7 +40,7 @@ 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') + await writeFile(join(root, '.credentials.yaml'), 'PI_COMPOSITION_KEY: key-from-store\n') const configPath = join(root, 'cordis.yml') await writeFile(configPath, [ @@ -54,7 +54,7 @@ async function loadComposition(): Promise<{ ctx: Context; settingsPath: string } '- id: credentials', " name: '@deepseek-ai/dsh-credentials-local'", ' config:', - ` path: ${JSON.stringify(join(root, '.env'))}`, + ` path: ${JSON.stringify(join(root, '.credentials.yaml'))}`, ' debounceMs: 10', '- id: llm-pi-ai', " name: '@deepseek-ai/dsh-llm-pi-ai'", diff --git a/packages/ui/app-boot/README.i18n.yaml b/packages/ui/app-boot/README.i18n.yaml index d565f6f11c..be3bb757a4 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: 7e0466c40583e6f5b22e0d5ef25d211d595c3216 -README.zh.md: abb796aaa9fd6f8e6ee0578423382ed7f23909ab +README.md: 8636af748168f6d898d7b44da298636af3686001 +README.zh.md: 0d956a3f5734cd04694fb96a6c89468e99413ebc diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index 7e0466c405..8636af7481 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -8,6 +8,7 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c |---|---| | `resolveConfigPath(path, snapshotMode, cwd?)` | Absolute config path; `snapshotMode === 'replay'` swaps a `cordis.yml`/`.yaml` basename for its sibling `cordis.snapshot.yml` | | `loadEnv(binName, dir?, warn?)` | Load the gitignored `.env` (Node `process.loadEnvFile`); absent file is fine, an unloadable one warns a single labelled line (default: stderr) | +| `loadLayeredEnv(binName, cwd?, warn?)` | The `dsh` product CLI's user environment: `loadEnv` over the invoking directory, then over the Harness home, giving `user < project < inherited`. The home is resolved from the inherited environment first, so a project `.env` cannot redirect it | | `installFailLoud(binName, proc?, release?)` | Turn an unhandled boot or later Loader rejection into one labelled stderr line + `exit(1)`; the optional `release` teardown is awaited between the two (bounded by `FAIL_LOUD_RELEASE_TIMEOUT_MS`) so a terminal-owning surface restores the terminal before exit; returns the uninstaller (for tests) | | `FAIL_LOUD_RELEASE_TIMEOUT_MS` | How long `installFailLoud` waits for its `release` hook; a wedged disposer delays the fatal exit, never cancels it | | `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber, reporting every unresolved plugin name as a Cordis startup failure | @@ -33,7 +34,7 @@ This package carries no loader hooks and no dev-mode surface. The [`dsh` app](.. A developer's machine-local preferences live outside every repository in the Harness home (default `~/.dsh`, overridable via `$DSH_HOME`; the single root [`resolveDshHome`](../../util/paths/README.md) resolves), consumed by the `dsh` CLI's TUI, Web, and headless surfaces ([`apps/cli`](../../../apps/cli/README.md)); the demo bins boot their committed trees verbatim. Two optional files: -- **`.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. +- **`.env`** — the user's ordinary environment layer, loaded by the `dsh` bin through `loadLayeredEnv` beneath the invoking directory's `.env` and the inherited environment. It is plain environment with plain environment reach, not a secret boundary: what the Harness owns and isolates lives in `.credentials.yaml`, which no surface hoists. A key placed in this file therefore still resolves — as a read-only `env` layer that shadows the stored one and blocks rotation from the TUI and the web page. - **`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. The TUI and Web keep `config.yaml` live through `watchPersonalPatches`; one-shot headless runs read only the startup value. The watcher targets the exact personal path even when the file or immediate parent does not exist, serializes bursts, and recomposes the personal patches inside the caller's layer order (surface overlay below, app-generated patches above). A rejected read, parse, or Loader candidate leaves the last good tree running and the HMR service broadcasts `hmr/config-update-failed(filename, Error)` after logging it; observer failures are contained. Disposing the context closes the watcher and drains an active refresh. @@ -52,5 +53,5 @@ No direct invalidation from `boot()`; a consumer that calls `addHarnessSourceSec - **Bare package specifiers depend on Loader internals** — production bins need Loader's optional native helper; an in-process caller without it must use resolvable relative/file specifiers or provide its own module-resolution hook. - **Snapshot replay swapping is basename-specific** — only a config ending in `cordis.yml` or `cordis.yaml` maps to the sibling `cordis.snapshot.yml`; custom config names require caller-managed selection. -- **Environment loading is cwd-scoped and optional** — the helper loads one `.env` file and warns on failure; it does not search parents, merge profiles, or validate required variables. +- **Environment loading is directory-scoped and optional** — each layer is one named directory's `.env`, and a failure warns; neither helper searches parents or validates required variables. `loadLayeredEnv` fixes its two layers at the invoking directory and the Harness home, so a caller wanting different layers composes `loadEnv` itself. - **Personal config is patch-shaped** — an id-targeted patch replaces the entry's whole `config` rather than deep-merging, so a personal override restates the base fields it keeps. diff --git a/packages/ui/app-boot/README.zh.md b/packages/ui/app-boot/README.zh.md index abb796aaa9..0d956a3f57 100644 --- a/packages/ui/app-boot/README.zh.md +++ b/packages/ui/app-boot/README.zh.md @@ -8,6 +8,7 @@ |---|---| | `resolveConfigPath(path, snapshotMode, cwd?)` | 生成绝对配置路径;当 `snapshotMode === 'replay'` 时,把 basename 为 `cordis.yml`/`.yaml` 的文件替换为同级 `cordis.snapshot.yml` | | `loadEnv(binName, dir?, warn?)` | 加载已被 git 忽略的 `.env`(Node `process.loadEnvFile`);文件不存在不影响启动,文件无法加载时输出一行带标签的警告(默认写入 stderr) | +| `loadLayeredEnv(binName, cwd?, warn?)` | `dsh` 产品 CLI(命令行界面)的用户环境:先对调用目录、再对 Harness home 调用 `loadEnv`,得到 `用户 < 项目 < 继承` 的层次。Harness home 先从继承的环境解析,因此项目 `.env` 无法改变它的指向 | | `installFailLoud(binName, proc?, release?)` | 将启动期或后续未处理的 Loader rejection 转换为一行带标签的 stderr 消息并执行 `exit(1)`;两者之间会等待可选的 `release` 拆卸回调(以 `FAIL_LOUD_RELEASE_TIMEOUT_MS` 为上限),使持有终端的界面能在退出前恢复终端;返回卸载函数(供测试使用) | | `FAIL_LOUD_RELEASE_TIMEOUT_MS` | `installFailLoud` 等待其 `release` 回调的时长;卡住的 disposer 只会延迟致命退出,而不会取消它 | | `assertEntriesLoaded(ctx, binName)` | 树结算后,如果其中存在已启用但没有 fiber 的条目,则抛出异常,并以 Cordis 启动故障的形式报告每个未解析插件的名称 | @@ -33,7 +34,7 @@ Loader 并发挂载各个条目,因此当其他环节失败时,某个界面 开发者的机器本地偏好位于所有仓库之外的 Harness home 中(默认 `~/.dsh`,可由 `$DSH_HOME` 覆盖;统一由根级 [`resolveDshHome`](../../util/paths/README.md) 解析),并由 `dsh` CLI(命令行界面)的 TUI、Web 和无头界面([`apps/cli`](../../../apps/cli/README.md))使用;demo bin 会原样启动仓库中提交的树。这里有两个可选文件: -- **`.env`**:[`dsh-credentials-local`](../../credentials/credentials-local/README.md) 的凭据存储,只由该 provider 读取。没有任何表层会把它提升进 `process.env`:那样做会让每个已存密钥在下次运行时看起来都像只读的启动时覆盖,从而阻断从 TUI 与 Web 页面轮换密钥。环境层次由环境中的值与调用目录的 `.env` 构成(由 bin 加载;`process.loadEnvFile` 从不覆盖已有值),没有凭据 provider 的组合仍然只从这两者解析密钥。 +- **`.env`**:用户的普通环境层,由 `dsh` bin 经 `loadLayeredEnv` 加载,位于调用目录的 `.env` 与继承环境之下。它是具有普通环境作用域的普通环境值,而不是密钥边界:由 Harness 拥有并隔离的东西放在 `.credentials.yaml` 里,后者不会被任何表层提升。因此放进本文件的密钥仍然可以解析——但会作为只读的 `env` 层遮蔽已存储的那一份,并阻断从 TUI 与 Web 页面轮换密钥。 - **`config.yaml`**:在发布的默认配置上应用 Loader overlay patch,语义与交付的 surface overlay 相同:按 id 定位的 patch 会替换对应条目的整个 `config`(未改字段也要重述),`insert` 会添加条目,`!!js` 表达式则在挂载时插值。如果 patch 指定的条目 id 不在已启动树中,则静默不执行任何操作。空文件或仅含注释的文件会抛出异常(其解析结果为空,而不是列表);如需禁用 overlay,请使用 `[]` 或删除该文件。 TUI 和 Web 会持续应用 `config.yaml` 的变更,具体由 `watchPersonalPatches` 负责;一次性无头运行只读取启动时的值。即使该文件或其直接父目录不存在,watcher 仍会监视确切的个人配置路径;它会串行处理突发变更,并按调用方的层次顺序重新组合个人 patch(surface overlay 在下、应用生成的 patch 在上)。读取失败、解析失败或 Loader 候选被拒时,最后一个可用树会继续运行;HMR 服务记录错误后广播 `hmr/config-update-failed(filename, Error)`,并隔离 observer 失败。上下文 dispose 时会关闭 watcher,并等待进行中的刷新结束。 @@ -52,5 +53,5 @@ TUI 和 Web 会持续应用 `config.yaml` 的变更,具体由 `watchPersonalPa - **裸包 specifier 依赖 Loader 内部机制**:生产 bin 需要 Loader 的可选原生 helper;没有该 helper 的进程内调用方必须使用可解析的相对/file specifier,或提供自己的模块解析钩子。 - **快照回放替换仅识别特定 basename**:只有以 `cordis.yml` 或 `cordis.yaml` 结尾的配置会映射到同级 `cordis.snapshot.yml`;自定义配置名称需要调用方自行选择。 -- **环境加载局限于 cwd 且为可选操作**:helper 只加载一个 `.env` 文件,并在失败时发出警告;它不会搜索父目录、合并 profile 或验证必需变量。 +- **环境加载按目录划分且为可选操作**:每一层都是一个指定目录下的 `.env`,失败时发出警告;两个 helper 都不会搜索父目录,也不验证必需变量。`loadLayeredEnv` 的两层固定为调用目录与 Harness home,需要其他层次的调用方请自行组合 `loadEnv`。 - **个人配置采用 patch 形式**:按 id 定位的 patch 会替换条目的整个 `config`,而不是深度合并,因此个人覆盖必须重述需要保留的基础字段。 diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index 7f3579cda1..94a7aa2c7d 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -1,6 +1,6 @@ /** * Shared boot glue for the app bins (`dsh`, `dsh-cli-demo`, `dsh-acp-demo`): load the gitignored - * `.env`, install the fail-loud Loader guards, resolve the config path (snapshot-aware), load the + * `.env` files, install the fail-loud Loader guards, resolve the config path (snapshot-aware), load the * optional personal overlay patches from the Harness home (`~/.dsh`), expose its path resolver to * config expressions, and drive the Cordis Loader against a leaf `cordis.yml` until the tree settles. * @module @deepseek-ai/dsh-app-boot @@ -65,6 +65,36 @@ export function loadEnv( } } +/** + * Load the dsh product CLI's user environment: the invoking directory's `.env` + * over the Harness home's `.env`, both under the inherited process + * environment. `process.loadEnvFile` never replaces a name that is already + * set, so loading the project file first and the user file second is what + * makes the layering `user < project < inherited`; the app-boot tests pin all + * three layers because that ordering is the whole contract. + * + * The Harness home is resolved from the inherited environment *before* either + * file loads, so a project `.env` can never redirect which user document is + * read. Only the product CLI layers these files: an SDK or example bin loads + * its own directory through {@link loadEnv} and must not inherit a developer's + * `$DSH_HOME`. + * + * These are ordinary environment values with ordinary environment reach. A + * secret the Harness should own and isolate belongs in the credentials + * document, which is never materialized here. + * @param binName - the diagnostic prefix on the warn lines. + * @param cwd - the invoking directory whose `.env` is the project layer. + * @param warn - sink for the one-line misconfiguration diagnostics. + */ +export function loadLayeredEnv( + binName: string, cwd: string = process.cwd(), + warn: (line: string) => void = line => void process.stderr.write(line), +): void { + const home = resolveDshHome() + loadEnv(binName, cwd, warn) + loadEnv(binName, home, warn) +} + /** File inside the Harness home holding the personal loader overlay patches. */ export const PERSONAL_CONFIG_FILENAME = 'config.yaml' diff --git a/packages/ui/app-boot/tests/app-boot.spec.ts b/packages/ui/app-boot/tests/app-boot.spec.ts index 96cad31ea3..ece98a9716 100644 --- a/packages/ui/app-boot/tests/app-boot.spec.ts +++ b/packages/ui/app-boot/tests/app-boot.spec.ts @@ -7,7 +7,7 @@ import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import { addHarnessSourceSection, assertEntriesActivated, assertEntriesLoaded, boot, FAIL_LOUD_RELEASE_TIMEOUT_MS, HARNESS_SOURCE_SECTION, - installFailLoud, loadEnv, loadOverlayPatches, resolveConfigPath, type FailLoudProcess, + installFailLoud, loadEnv, loadLayeredEnv, loadOverlayPatches, resolveConfigPath, type FailLoudProcess, } from '../src/index.ts' const NAME = 'dsh-test-bin' @@ -86,6 +86,66 @@ describe('loadEnv', () => { }) }) +describe('loadLayeredEnv', () => { + const NAMES = ['DSH_APP_BOOT_LAYERED_SHARED', 'DSH_APP_BOOT_LAYERED_USER', 'DSH_APP_BOOT_LAYERED_PROJECT'] as const + + function clear(): void { + for (const name of NAMES) Reflect.deleteProperty(process.env, name) + } + + it('layers user under project under the inherited environment', () => { + const home = tmp() + const project = tmp() + writeFileSync(join(home, '.env'), [ + `${NAMES[0]}=user`, + `${NAMES[1]}=user-only`, + 'DSH_APP_BOOT_LAYERED_INHERITED=user-loses', + '', + ].join('\n')) + writeFileSync(join(project, '.env'), [ + `${NAMES[0]}=project`, + `${NAMES[2]}=project-only`, + 'DSH_APP_BOOT_LAYERED_INHERITED=project-loses', + '', + ].join('\n')) + clear() + vi.stubEnv('DSH_HOME', home) + vi.stubEnv('DSH_APP_BOOT_LAYERED_INHERITED', 'inherited') + const warn = vi.fn() + try { + loadLayeredEnv(NAME, project, warn) + // Both files load; the project layer wins the name they share, and the + // inherited environment wins over both. + expect(process.env[NAMES[0]]).toBe('project') + expect(process.env[NAMES[1]]).toBe('user-only') + expect(process.env[NAMES[2]]).toBe('project-only') + expect(process.env['DSH_APP_BOOT_LAYERED_INHERITED']).toBe('inherited') + expect(warn).not.toHaveBeenCalled() + } finally { + clear() + vi.unstubAllEnvs() + } + }) + + it('resolves the harness home before the project file can redirect it', () => { + const home = tmp() + const decoy = tmp() + const project = tmp() + writeFileSync(join(home, '.env'), `${NAMES[1]}=real-home\n`) + writeFileSync(join(decoy, '.env'), `${NAMES[1]}=decoy-home\n`) + writeFileSync(join(project, '.env'), `DSH_HOME=${decoy}\n`) + clear() + vi.stubEnv('DSH_HOME', home) + try { + loadLayeredEnv(NAME, project, vi.fn()) + expect(process.env[NAMES[1]]).toBe('real-home') + } finally { + clear() + vi.unstubAllEnvs() + } + }) +}) + describe('installFailLoud', () => { function fakeProc(): FailLoudProcess & { handlers: Array<(err: unknown) => void>; written: string[]; exits: number[] } { const handlers: Array<(err: unknown) => void> = [] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5e27760b0d..a74fc2677f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2625,12 +2625,12 @@ importers: chokidar: specifier: ^4.0.3 version: 4.0.3 - dotenv: - specifier: ^17.2.0 - version: 17.4.2 schemastery: specifier: ^3.18.0 version: link:../../../vendor/schemastery + yaml: + specifier: ^2.9.0 + version: 2.9.0 devDependencies: '@deepseek-ai/dsh-atomic-write': specifier: workspace:^ @@ -9776,10 +9776,6 @@ 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} @@ -14832,8 +14828,6 @@ 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 From 8ddc53f7a036acb3efcf0a26827e04bbe6830430 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 4 Aug 2026 15:25:04 +0800 Subject: [PATCH 03/27] feat(cli)!: complete --config on every surface and delete the personal overlay $DSH_HOME/config.yaml was an implicit composition layer: if the file existed, every launch applied an arbitrary Loader patch graph over the shipped tree, kept live by a dedicated HMR watcher. Three costs came from the implicitness, not the capability. A patch replaces its target row's whole config, so a file written months ago pins that row to the field set it knew and every default the shipped tree later adds silently stops applying. It competed with the typed settings namespaces llm-deepseek and llm-pi-ai already register, so which one wins was a function of layer order rather than meaning. And the explicit escape hatch it was supposedly redundant with did not exist on every surface: dsh -p, dsh meta, and dsh upgrade all rejected --config, so for them the implicit file was the only composition route at all. Complete the explicit layer first: --config and --config-replace now work on every booting surface. A headless --config-replace tree must still mount a webserver row, because that surface reaches its own agent over the same HTTP gateway the browser uses; AppCLIEntry names that contract in the failure instead of reporting a bare missing service. Then delete the implicit one. PERSONAL_CONFIG_FILENAME, loadPersonalPatches, watchPersonalPatches, and the config-only HMR row mounted for it are gone; a file left at that path is inert, and --dump-config no longer reads the Harness home. --config therefore stops *replacing* the personal overlay and simply *is* the user overlay. No migration: a user who wants the old behavior names the same file (dsh --config ~/.dsh/config.yaml), which a shell alias makes permanent. --- ...26-07-20-dsh-cli-personal-config.i18n.yaml | 4 +- .../2026-07-20-dsh-cli-personal-config.md | 4 +- .../2026-07-20-dsh-cli-personal-config.zh.md | 4 +- ...7-29-shared-base-config-overlays.i18n.yaml | 4 +- .../2026-07-29-shared-base-config-overlays.md | 4 +- ...26-07-29-shared-base-config-overlays.zh.md | 4 +- ...emove-personal-composition-layer.i18n.yaml | 6 + ...08-04-remove-personal-composition-layer.md | 47 +++ ...04-remove-personal-composition-layer.zh.md | 47 +++ apps/cli/README.i18n.yaml | 4 +- apps/cli/README.md | 12 +- apps/cli/README.zh.md | 12 +- apps/cli/config/base.cordis.yml | 4 +- apps/cli/src/app-cli-entry.ts | 95 +++--- apps/cli/src/args.ts | 113 +++++--- apps/cli/src/bin.ts | 6 +- apps/cli/src/dump-config.ts | 25 +- apps/cli/src/headless.ts | 10 +- apps/cli/src/tui.ts | 48 ++-- apps/cli/src/web.ts | 3 +- apps/cli/tests/args.spec.ts | 26 +- apps/cli/tests/built-bin.e2e.ts | 16 +- apps/cli/tests/tui-keyless-smoke.e2e.ts | 52 ++-- docs/user/guide/config.i18n.yaml | 4 +- docs/user/guide/config.md | 2 +- docs/user/guide/config.zh.md | 2 +- examples/mcp-memory/README.i18n.yaml | 4 +- examples/mcp-memory/README.md | 2 +- examples/mcp-memory/README.zh.md | 2 +- .../cordis/repository-plugin/README.i18n.yaml | 4 +- packages/cordis/repository-plugin/README.md | 4 +- .../cordis/repository-plugin/README.zh.md | 4 +- packages/ui/app-boot/README.i18n.yaml | 4 +- packages/ui/app-boot/README.md | 18 +- packages/ui/app-boot/README.zh.md | 18 +- packages/ui/app-boot/src/index.ts | 150 ++-------- .../ui/app-boot/tests/config-dump.spec.ts | 12 +- .../ui/app-boot/tests/config-reload.spec.ts | 16 +- .../ui/app-boot/tests/personal-config.spec.ts | 270 ------------------ 39 files changed, 416 insertions(+), 650 deletions(-) create mode 100644 .agents/notes/implemented/simplification/2026-08-04-remove-personal-composition-layer.i18n.yaml create mode 100644 .agents/notes/implemented/simplification/2026-08-04-remove-personal-composition-layer.md create mode 100644 .agents/notes/implemented/simplification/2026-08-04-remove-personal-composition-layer.zh.md delete mode 100644 packages/ui/app-boot/tests/personal-config.spec.ts diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml index e4e9dfb93a..8bdb3fc8c0 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.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 .agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md -2026-07-20-dsh-cli-personal-config.md: 1fa8cda2b34b58cc7a28b722872520b68a9b7009 -2026-07-20-dsh-cli-personal-config.zh.md: e70b8914cf005e0a2e54ba2b29d3b7def84b00db +2026-07-20-dsh-cli-personal-config.md: 3770fdbcac038874c8beb3071217ef40942f8dfe +2026-07-20-dsh-cli-personal-config.zh.md: dcecf8749b29fd1023516570490adf2b256d0b35 diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md index 1fa8cda2b3..3770fdbcac 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md @@ -17,7 +17,7 @@ Two coupled pieces, aligned with the `apps/` assembly tier proposed by the `dsh **Personal config (`dsh-app-boot`).** The personal overlay lives in the Harness home — `$DSH_HOME`, else `~/.dsh` — resolved by the shared [`resolveDshHome`](../architecture/2026-07-24-single-harness-home-resolver.md) (`@deepseek-ai/dsh-paths`), the same single root skills and AGENTS.md resolve against. The dsh TUI, Web, and headless surfaces consume its two optional files; the demo bins boot their committed trees verbatim: - `.env` — loaded after the invoking directory's `.env`; `process.loadEnvFile` never overrides, so precedence is ambient > project `.env` > personal `.env`. -- `config.yaml` — a top-level YAML array of `@cordisjs/plugin-include` `PatchOptions`, parsed with the include's own `!!js` dialect (`loadPersonalPatches`) and passed to `boot()`, which forwards it as the root include's `patches`. Patch semantics match the shipped surface overlays: an id-targeted patch replaces the named entry's whole `config`, `insert` appends entries, and an unmatched id is a silent no-op. The [repository Plugin integration](2026-07-30-config-only-repository-plugins.md) uses one shipped row to make an exact GitHub source list a config-only choice. +- `config.yaml` — [removed with the personal composition layer](../simplification/2026-08-04-remove-personal-composition-layer.md); while it existed, a top-level YAML array of `@cordisjs/plugin-include` `PatchOptions`, parsed with the include's own `!!js` dialect (`loadPersonalPatches`) and passed to `boot()`, which forwarded it as the root include's `patches`. Patch semantics match the shipped surface overlays: an id-targeted patch replaces the named entry's whole `config`, `insert` appends entries, and an unmatched id is a silent no-op. The [repository Plugin integration](2026-07-30-config-only-repository-plugins.md) uses one shipped row to make an exact GitHub source list a config-only choice. - A missing file means no overlay; a present-but-unreadable, unparsable, or non-array file throws at boot (misconfiguration fails loud, never a silent skip). The PTY smoke's launcher isolates `$DSH_HOME` to a per-test directory, exactly as it already isolates `DSH_AGENTS_HOME`, so a developer's real personal overlay cannot leak into fixtures; only the dsh CLI reads personal config, so no other test launcher needed changes. @@ -46,4 +46,4 @@ The TUI and Web register the exact personal path through Cordis HMR after boot. ## Testing -`packages/ui/app-boot/tests/personal-config.spec.ts` pins parsing, startup application, exact-path add/failure/recovery/removal, last-good rollback, failure broadcast, and preservation of app-owned patches. `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` boots the real dsh bin with no overlay, a personal environment and UI patch, a config-only cached repository skill, and invalid personal YAML. Test launchers isolate `$DSH_HOME`, so a developer's real overlay cannot leak into fixtures. +The overlay's own spec covered parsing, startup application, exact-path add/failure/recovery/removal, last-good rollback, failure broadcast, and preservation of app-owned patches; it was deleted with the layer. `apps/cli/tests/tui-keyless-smoke.e2e.ts` still boots the real dsh bin with no overlay, with a named `--config` environment and UI patch, with a config-only cached repository skill, and with invalid overlay YAML. Test launchers isolate `$DSH_HOME`, so a developer's real overlay cannot leak into fixtures. diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md index e70b8914cf..dcecf8749b 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md @@ -17,7 +17,7 @@ Status: implemented **个人配置(`dsh-app-boot`)。** 个人 overlay 存放在 Harness home——`$DSH_HOME`,否则 `~/.dsh`——由共享的 [`resolveDshHome`](../architecture/2026-07-24-single-harness-home-resolver.md)(`@deepseek-ai/dsh-paths`)解析,与 skills、AGENTS.md 解析所依据的单一根目录相同。dsh 的 TUI、Web 和无头界面使用其中两个可选文件;各示例 bin 仍然逐字节按已提交的配置树启动: - `.env`——在调用目录的 `.env` 之后加载;`process.loadEnvFile` 从不覆盖已有值,因此优先级为环境变量 > 项目 `.env` > 个人 `.env`。 -- `config.yaml`——顶层 YAML 数组,元素为 `@cordisjs/plugin-include` 的 `PatchOptions`,用 include 自己的 `!!js` 方言解析(`loadPersonalPatches`)并传给 `boot()`,由它作为根 include 的 `patches` 转发。补丁语义与交付的 surface overlay 一致:按 id 定位的补丁替换该配置项的整个 `config`,`insert` 追加配置项,未匹配的 id 静默不执行任何操作。[仓库插件集成](2026-07-30-config-only-repository-plugins.md)通过一个已交付配置项,使精确 GitHub 源列表成为纯配置选择。 +- `config.yaml`——[已随个人 composition 层一并删除](../simplification/2026-08-04-remove-personal-composition-layer.md);它存在期间是顶层 YAML 数组,元素为 `@cordisjs/plugin-include` 的 `PatchOptions`,用 include 自己的 `!!js` 方言解析(`loadPersonalPatches`)并传给 `boot()`,由它作为根 include 的 `patches` 转发。补丁语义与交付的 surface overlay 一致:按 id 定位的补丁替换该配置项的整个 `config`,`insert` 追加配置项,未匹配的 id 静默不执行任何操作。[仓库插件集成](2026-07-30-config-only-repository-plugins.md)通过一个已交付配置项,使精确 GitHub 源列表成为纯配置选择。 - 文件缺失即无 overlay;文件存在但不可读、不可解析或非数组则在启动时抛出(配置错误响亮失败,绝不静默跳过)。 PTY 冒烟测试的启动器把 `$DSH_HOME` 隔离到每个测试自己的目录,与它已有的 `DSH_AGENTS_HOME` 隔离方式完全一致,开发者真实的个人 overlay 不可能泄漏进 fixture;只有 dsh CLI 读取个人配置,因此其他测试启动器无需改动。 @@ -46,4 +46,4 @@ TUI 和 Web 启动后通过 Cordis HMR(热模块替换)注册确切的个人 ## Testing -`packages/ui/app-boot/tests/personal-config.spec.ts` 固定解析、启动时应用、确切路径的新增/失败/恢复/移除、最后可用状态回滚、失败广播以及应用自有 patch 的保留。`examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` 启动真实 dsh bin,覆盖无 overlay、个人环境与 UI patch、纯配置的缓存 repository skill,以及无效个人 YAML。测试启动器会隔离 `$DSH_HOME`,因此开发者的真实 overlay 不会泄漏进 fixture。 +该 overlay 自己的 spec 曾固定解析、启动时应用、确切路径的新增/失败/恢复/移除、最后可用状态回滚、失败广播以及应用自有 patch 的保留;它已随该层一并删除。`apps/cli/tests/tui-keyless-smoke.e2e.ts` 仍然启动真实 dsh bin,覆盖无 overlay、点名 `--config` 的环境与 UI patch、纯配置的缓存 repository skill,以及无效的 overlay YAML。测试启动器会隔离 `$DSH_HOME`,因此开发者的真实 overlay 不会泄漏进 fixture。 diff --git a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.i18n.yaml index b100535da6..90523174f6 100644 --- a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.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 .agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md -2026-07-29-shared-base-config-overlays.md: ee642cbc786bef708791fb58e655c5a3f0e9c4e7 -2026-07-29-shared-base-config-overlays.zh.md: b7fc9c6b121b8d0eb94d734af6bda6df45e25b1d +2026-07-29-shared-base-config-overlays.md: 494adcfc9efe2c88a67efd8a7ad2e5e0a2a39b4d +2026-07-29-shared-base-config-overlays.zh.md: 919350db03420f9a5190c96e02fe774b6d2cb346 diff --git a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md index ee642cbc78..494adcfc9e 100644 --- a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md +++ b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md @@ -18,9 +18,9 @@ One shared base, one overlay per surface, composed as sibling patch lists. `apps/cli/config/base.cordis.yml` holds the 43 rows both surfaces mount. `apps/cli/config/tui.cordis.yml` and `apps/cli/config/web.cordis.yml` are **patch lists**, not trees: each states the handful of rows whose value is surface-specific and inserts its own rows. The launcher includes the base once and applies every overlay as a sibling patch list at **one** include level, because include patches never cross an include boundary — stacking overlays as nested includes would silently stop reaching base rows. -Precedence is list order, last write winning per row: base, then the surface overlay, then either a `--config` overlay or the personal `~/.dsh/config.yaml`, then the launcher's own flag and profile patches. +Precedence is list order, last write winning per row: base, then the surface overlay, then a `--config` overlay, then the launcher's own flag patches. The personal `~/.dsh/config.yaml` sat in the `--config` slot until it was [removed with the personal composition layer](../simplification/2026-08-04-remove-personal-composition-layer.md). -`--config ` now applies an overlay **instead of** the personal overlay, so a demo or test tree never inherits the user's provider and model. `--config-replace ` boots a file as the entire tree, bypassing base, surface overlay, and personal overlay alike; that is what the old `--config` did, so trees like `examples/web-cordis` moved to the new flag. Both flags survive the `/resume` execve handoff, or resuming would silently change the agent. +`--config ` applies an overlay over the shipped tree (at the time, **instead of** the personal overlay, so a demo or test tree never inherited the user's provider and model). `--config-replace ` boots a file as the entire tree, bypassing base, surface overlay, and personal overlay alike; that is what the old `--config` did, so trees like `examples/web-cordis` moved to the new flag. Both flags survive the `/resume` execve handoff, or resuming would silently change the agent. A patch replaces its target row's whole `config` rather than merging, which shapes the split: a row whose value differs per surface lives in the overlays, never in the base, so no row is patched by three layers at once. Session identity therefore cannot ride a config key at all — it moved to `dsh-agent-loop`'s `CONFIGURED_AGENT_IDENTITIES_KEY`, as [the launcher-owned identity note](../architecture/2026-07-28-launcher-owned-resume-identity.md) now records. diff --git a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.zh.md b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.zh.md index b7fc9c6b12..919350db03 100644 --- a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.zh.md @@ -18,9 +18,9 @@ Status: implemented `apps/cli/config/base.cordis.yml` 持有两个 surface 都会挂载的 43 个配置项。`apps/cli/config/tui.cordis.yml` 与 `apps/cli/config/web.cordis.yml` 是 **patch 列表**,不是配置树:各自声明少数取值因 surface 而异的配置项,并 insert 自己的配置项。启动器只 include base 一次,并把每个 overlay 作为**同一** include 层级上的平级 patch 列表应用——因为 include patch 不会跨越 include 边界,把 overlay 堆叠成嵌套 include 会使其静默地无法触达 base 配置项。 -优先级即列表顺序,逐配置项后写者胜:base,然后是 surface overlay,接着是 `--config` overlay 或个人 `~/.dsh/config.yaml`,最后是启动器自身的 flag 与 profile patch。 +优先级即列表顺序,逐配置项后写者胜:base,然后是 surface overlay,接着是 `--config` overlay,最后是启动器自身的 flag patch。个人 `~/.dsh/config.yaml` 曾占据 `--config` 这一槽位,直到它[已随个人 composition 层一并删除](../simplification/2026-08-04-remove-personal-composition-layer.md)。 -`--config ` 现在应用一个 overlay 来**取代**个人 overlay,因此 demo 或测试用的树绝不会继承用户的 provider 与 model。`--config-replace ` 则把某个文件作为整棵树启动,同时绕过 base、surface overlay 与个人 overlay;这正是旧 `--config` 的行为,所以像 `examples/web-cordis` 这样的树改用了新 flag。两个 flag 都会在 `/resume` 的 execve 交接中保留,否则 resume 会静默更换 agent。 +`--config ` 在已交付配置树上应用一个 overlay(当时是**取代**个人 overlay,因此 demo 或测试用的树绝不会继承用户的 provider 与 model)。`--config-replace ` 则把某个文件作为整棵树启动,同时绕过 base、surface overlay 与个人 overlay;这正是旧 `--config` 的行为,所以像 `examples/web-cordis` 这样的树改用了新 flag。两个 flag 都会在 `/resume` 的 execve 交接中保留,否则 resume 会静默更换 agent。 patch 会整体替换目标配置项的 `config` 而不合并,这决定了拆分方式:取值因 surface 而异的配置项住在 overlay 中,绝不住在 base 里,从而没有任何配置项会被三层同时 patch。因此会话身份根本不能经由配置键传递——它迁移到了 `dsh-agent-loop` 的 `CONFIGURED_AGENT_IDENTITIES_KEY`,如[启动器持有身份的 note](../architecture/2026-07-28-launcher-owned-resume-identity.md) 现在所记录。 diff --git a/.agents/notes/implemented/simplification/2026-08-04-remove-personal-composition-layer.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-04-remove-personal-composition-layer.i18n.yaml new file mode 100644 index 0000000000..11239d3c23 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-04-remove-personal-composition-layer.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/simplification/2026-08-04-remove-personal-composition-layer.md +2026-08-04-remove-personal-composition-layer.md: 941e2248e15e235037e6bd48dcb3ba6c80bd83dd +2026-08-04-remove-personal-composition-layer.zh.md: 6c6f3ecd541590368624f4ed4bd409321a2f9772 diff --git a/.agents/notes/implemented/simplification/2026-08-04-remove-personal-composition-layer.md b/.agents/notes/implemented/simplification/2026-08-04-remove-personal-composition-layer.md new file mode 100644 index 0000000000..941e2248e1 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-04-remove-personal-composition-layer.md @@ -0,0 +1,47 @@ +# Agent Note: Removing the personal composition layer + +Status: implemented + +English | [中文](2026-08-04-remove-personal-composition-layer.zh.md) + +## Problem + +`$DSH_HOME/config.yaml` was an implicit composition layer: if the file existed, every `dsh` launch applied an arbitrary Loader patch graph over the shipped tree, and the TUI and Web kept it live through a dedicated HMR watcher. Three costs followed from the implicitness rather than from the capability. + +A patch replaces its target row's whole `config`, so a personal file written months ago pins that row to the field set it knew. Every default the shipped tree later adds to that row silently stops applying, and nothing surfaces it short of running `--dump-config`. Applying that on every launch turns a one-time edit into a standing divergence. + +It also competed with typed settings for the same values. `llm-deepseek` and `llm-pi-ai` register settings namespaces, and the same fields are reachable by patching their rows — so which one wins is a function of layer order, not of what the value means. That is the ownership ambiguity the [user-settings seam](../architecture/2026-07-28-user-settings-seam.md) exists to remove. + +Finally the escape hatch it was supposed to be redundant with did not cover every surface: `dsh -p`, `dsh meta`, and `dsh upgrade` all rejected `--config`. For those surfaces the implicit file was not one composition route among two — it was the only one. + +## Decision + +The implicit layer is deleted and the explicit one is completed. + +**Every booting surface takes `--config` and `--config-replace`.** `dsh -p`, `dsh meta`, and `dsh upgrade` join the TUI, so naming a tree is available wherever a tree boots. A headless `--config-replace` tree must still mount a webserver row, because that surface reaches its own agent over the same HTTP gateway the browser uses; `AppCLIEntry` now names that contract in the failure instead of reporting a bare missing service. + +**`$DSH_HOME/config.yaml` is not read, watched, or dumped.** `PERSONAL_CONFIG_FILENAME`, `loadPersonalPatches`, `watchPersonalPatches`, and the config-only HMR row mounted for it are deleted. A file left at that path is inert. The Harness home keeps `settings.yaml`, `.credentials.yaml`, and `.env`; an overlay may still live there, but as a path to name, not a layer to discover. + +`--config` therefore changes meaning slightly: it used to *replace* the personal overlay, and now it simply *is* the user overlay. `--config-replace` is unchanged. + +Everyday capabilities keep their owners. Model and provider parameters already belong to the adapters' typed settings namespaces. The `repository-plugins` row ships mounted with an empty list, so a repository Plugin list is a `--config` overlay today and a settings namespace when one lands. MCP servers stay a `--config` composition, which is what [the CLI README](../../../../apps/cli/README.md) now documents. + +There is no migration and no deprecation diagnostic: the product is unreleased, and a user who wants the old behavior names the same file (`dsh --config ~/.dsh/config.yaml`), which a shell alias makes permanent. + +## Consequences + +- Given up: a composition that follows you across launches without being named. Restoring it is an alias, which is the point — the graph is now something a launch declares rather than something the machine holds. +- Given up: live reload of a composition file. Settings and credentials keep their own watchers; a composition change now takes a restart, which is what `--config` already meant for every explicit tree. +- Bought: one composition route instead of two, a shipped tree that cannot be silently pinned to a stale field set, and typed settings as the uncontested owner of the values they declare. +- The [personal-config feature note](../feature/2026-07-20-dsh-cli-personal-config.md) is only partially superseded — the `dsh` CLI it introduced stands — so both notes stay cross-linked and its config-overlay facts were rewritten in place. +- `--dump-config` prints the shipped base, the surface overlay, and any named `--config`; with no flag it prints the shipped composition alone, so the Harness home no longer changes what a dump shows. + +## Alternatives considered + +**Keep the file but stop watching it.** Rejected: the watcher is the smaller half. The standing cost is that an old patch list silently pins a shipped row on every launch, which a startup-only read preserves exactly. + +**Name the overlay from `settings.yaml` (`compositionOverlay: ~/.dsh/my.cordis.yml`).** Rejected, and worth stating because it looks like the best of both: it keeps the runtime property that motivated the removal — every launch applies an arbitrary plugin graph — and only changes the trigger from "file exists" to "field is set". Worse, `settings.yaml` is written by the product's own settings UI, so it would let a settings page edit the composition tree. + +**Delete it only after the settings-driven repository and MCP managers exist.** Rejected as an unnecessary dependency once `--config` reached every surface: the managers make those two cases *nicer*, but with the flag available everywhere, nothing is lost by removing the implicit layer first. + +**Keep it for `dsh -p` alone, where no flag existed.** Rejected: that is the surface with the strongest case for explicitness. A CI or scripted run should name its composition rather than inherit whatever the machine holds. diff --git a/.agents/notes/implemented/simplification/2026-08-04-remove-personal-composition-layer.zh.md b/.agents/notes/implemented/simplification/2026-08-04-remove-personal-composition-layer.zh.md new file mode 100644 index 0000000000..6c6f3ecd54 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-04-remove-personal-composition-layer.zh.md @@ -0,0 +1,47 @@ +# Agent Note: 删除个人 composition 层 + +Status: implemented + +[English](2026-08-04-remove-personal-composition-layer.md) | 中文 + +## Problem + +`$DSH_HOME/config.yaml` 是一个隐式的 composition 层:只要该文件存在,每次 `dsh` 启动都会在已交付配置树上应用一张任意的 Loader patch 图,而 TUI 与 Web 还用一个专门的 HMR watcher 让它保持热更新。随之而来的三项代价来自「隐式」,而不是来自这项能力本身。 + +patch 会替换目标行的整个 `config`,因此几个月前写下的个人文件会把那一行钉死在它当时知道的字段集上。此后交付端给该行新增的每个默认值都会静默失效,而除非跑 `--dump-config`,否则没有任何东西会暴露这一点。每次启动都应用它,等于把一次性编辑变成了长期偏离。 + +它还在同一批值上与类型化 settings 争夺所有权。`llm-deepseek` 与 `llm-pi-ai` 都注册了 settings namespace,而同样的字段也能通过 patch 它们的行抵达——于是谁赢取决于层序,而不取决于这个值的语义。这正是 [user-settings seam](../architecture/2026-07-28-user-settings-seam.md) 要消除的所有权歧义。 + +最后,本应与它互为冗余的那条显式通道并未覆盖所有界面:`dsh -p`、`dsh meta` 和 `dsh upgrade` 都拒绝 `--config`。对这些界面来说,隐式文件不是两条 composition 路径之一——它是唯一的一条。 + +## Decision + +删掉隐式的那一层,并把显式的那一层补完整。 + +**每个会启动的界面都接受 `--config` 与 `--config-replace`。** `dsh -p`、`dsh meta` 和 `dsh upgrade` 与 TUI 看齐,因此只要有配置树启动的地方,就能点名一棵树。无头模式下的 `--config-replace` 树仍必须挂载 webserver 行,因为该界面是通过浏览器所用的同一个 HTTP 网关访问自己的 agent 的;`AppCLIEntry` 现在会在失败信息里说明这条契约,而不是只报告某个服务缺失。 + +**`$DSH_HOME/config.yaml` 不再被读取、监视或 dump。** `PERSONAL_CONFIG_FILENAME`、`loadPersonalPatches`、`watchPersonalPatches`,以及专为它挂载的那一行 config-only HMR,全部删除。留在该路径上的文件是惰性的。Harness home 仍然保有 `settings.yaml`、`.credentials.yaml` 和 `.env`;overlay 也仍然可以放在那里,但它是一条待点名的路径,而不是一层待发现的配置。 + +因此 `--config` 的含义略有变化:它过去是*替代*个人 overlay,现在它本身*就是*用户 overlay。`--config-replace` 保持不变。 + +日常能力各自保有归属。模型与 provider 参数已经属于各适配器的类型化 settings namespace。`repository-plugins` 行随交付配置以空列表挂载,因此仓库插件列表今天是一个 `--config` overlay,等 settings namespace 落地后归它。MCP 服务器仍然是 `--config` composition,这也是 [CLI README](../../../../apps/cli/README.md) 现在的写法。 + +不做迁移,也不给弃用诊断:产品尚未发布,想要旧行为的用户点名同一个文件即可(`dsh --config ~/.dsh/config.yaml`),配一个 shell alias 就是永久的。 + +## Consequences + +- 放弃的:一份无需点名就跨启动跟随你的 composition。恢复它只需一个 alias,而这正是重点——插件图现在由一次启动声明,而不是由机器持有。 +- 放弃的:composition 文件的热重载。settings 与凭据各自保留 watcher;composition 变更现在需要重启,而这本来就是 `--config` 对每一棵显式树的既有含义。 +- 换来的:只有一条 composition 路径而不是两条;已交付配置树不会被静默钉死在陈旧字段集上;类型化 settings 成为其所声明的值的唯一所有者。 +- [个人配置特性 Note](../feature/2026-07-20-dsh-cli-personal-config.md) 只被部分取代——它引入的 `dsh` CLI(命令行界面)仍然成立——因此两条 Note 保持互链,其中关于 config overlay 的事实已就地改写。 +- `--dump-config` 打印已交付基座、surface overlay 以及任何被点名的 `--config`;不带标志时只打印已交付组合,因此 Harness home 不再改变 dump 的内容。 + +## Alternatives considered + +**保留该文件,只是不再监视它。** 否决:watcher 是较小的那一半。长期代价在于一份旧 patch 列表会在每次启动时静默钉死一个已交付行,而只在启动时读取恰恰完整保留了这一点。 + +**从 `settings.yaml` 里点名 overlay(`compositionOverlay: ~/.dsh/my.cordis.yml`)。** 否决,且值得写明,因为它看起来两全其美:它保留了促成本次删除的那条运行时性质——每次启动都应用一张任意插件图——只是把触发条件从「文件存在」换成「字段已设置」。更糟的是,`settings.yaml` 由产品自己的设置界面写入,那等于让设置页面能编辑 composition 树。 + +**等 settings 驱动的 repository 与 MCP manager 落地后再删。** 在 `--config` 覆盖所有界面之后,这条依赖已无必要,故否决:那两个 manager 会让这两种场景*更好用*,但只要标志处处可用,先删掉隐式层就不损失任何东西。 + +**只为 `dsh -p` 保留它,因为那里原本没有标志。** 否决:那恰恰是最需要显式的界面。CI 或脚本化运行应当点名自己的 composition,而不是继承机器上恰好存在的东西。 diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index 96a4588f2c..44cb46a317 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: 76d9ed65398322cb9244a31661ee59b60c23f793 -README.zh.md: 16a7a4ec52b830e45c32a61a103d87be5941ab3b +README.md: 3195fb4856ec794186658afd5e329cd58e6a3b28 +README.zh.md: 011cacb347aff88f6a04544dcd9b5e9b8d434c18 diff --git a/apps/cli/README.md b/apps/cli/README.md index 76d9ed6539..3195fb4856 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -7,11 +7,11 @@ Argv is parsed once through a [Commander](https://github.com/tj/commander.js) ad The TUI surface: -- boots `base.cordis.yml` plus `tui.cordis.yml` through [`dsh-app-boot`](../../packages/ui/app-boot/README.md); `--config ` applies a patch-list overlay instead of the personal overlay, while `--config-replace ` boots that file as the complete tree; +- boots `base.cordis.yml` plus `tui.cordis.yml` through [`dsh-app-boot`](../../packages/ui/app-boot/README.md); `--config ` applies a patch-list overlay over that tree, while `--config-replace ` boots the named file as the complete tree; every booting surface takes both flags; - 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)): `config.yaml` patches the booted tree, while `.env` there is the credential provider's own store (never hoisted into the environment, so keys stay rotatable). Environment precedence is ambient > project `.env`. The shipped tree's Cordis HMR keeps `config.yaml` live; an explicit `--config` tree replaces that overlay, and a tree without HMR reads it at startup only. +- reads the Harness home (`~/.dsh`) for user state only (see [app-boot's Harness home](../../packages/ui/app-boot/README.md#the-harness-home)): `.env` is the user environment layer and `.credentials.yaml` is the credential provider's own store, never hoisted into the environment, so keys stay rotatable. Environment precedence is ambient > project `.env` > user `.env`. No composition file is discovered there: an overlay reaches a launch only through `--config`. - presents the [versioned first-run welcome](../../.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md) through the mounted TUI overlay service when its immutable marker is absent under `DSH_HOME`; only Enter creates that version's marker, while Escape, disposal, or process exit leaves it eligible. The official DeepSeek icon, responsive terminal rasters, all-locale Chinese copy, and notice version are static local owners; the overlay never writes a session event or model context. - registers bare `/compact`: while the agent is idle, it summarizes useful older history even below automatic pressure, rejects arguments, and reports success only after the standalone replacement bracket is durable. A prompt submitted during compaction keeps its queue identity and starts after that checkpoint; injected context remains visible. @@ -19,13 +19,13 @@ The TUI surface: `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. The command takes no options beyond the experimental gate — `--config`, `-p`, and `--resume` fail loud — and seeds only on this first launch, so a later `dsh --resume ` of the session is an ordinary TUI session with no re-injection. -`dsh --dump-config` and `dsh web --dump-config` print the composed config tree — the shipped base, the surface overlay, and the `--config` or personal overlay, exactly the layers that surface would boot — as YAML on stdout and exit without booting; `--dump-default-config` stops at the surface overlay, so diffing the two shows precisely what the user layer changes. Each run of rows is preceded by a `# ==` comment naming the file it comes from and the layers that patched it (e.g. `# == base.cordis.yml, patched by tui.cordis.yml`), so the output shows provenance while staying one loadable document. Composition runs through the include's own patch algorithm and YAML dialect (`applyEntryPatches`/`entryListSchema` from `@cordisjs/plugin-include`), so the dump cannot drift from what boots; `!!js` expressions print verbatim and unevaluated, and a patch whose target row is absent is reported on stderr with its layer, mirroring the Loader's boot-time warning. Launcher-owned boot-context values (session identity, CLI-flag patches) are per-invocation facts outside the config tree and do not appear. The dump flags reject boot-only flags (`-p`, `--resume`, `--config-replace`) rather than silently ignoring them, and `--dump-default-config` takes no `--config`. +`dsh --dump-config` and `dsh web --dump-config` print the composed config tree — the shipped base, the surface overlay, and any `--config` overlay, exactly the layers that surface would boot — as YAML on stdout and exit without booting; `--dump-default-config` stops at the surface overlay, so diffing the two shows precisely what the user layer changes. Each run of rows is preceded by a `# ==` comment naming the file it comes from and the layers that patched it (e.g. `# == base.cordis.yml, patched by tui.cordis.yml`), so the output shows provenance while staying one loadable document. Composition runs through the include's own patch algorithm and YAML dialect (`applyEntryPatches`/`entryListSchema` from `@cordisjs/plugin-include`), so the dump cannot drift from what boots; `!!js` expressions print verbatim and unevaluated, and a patch whose target row is absent is reported on stderr with its layer, mirroring the Loader's boot-time warning. Launcher-owned boot-context values (session identity, CLI-flag patches) are per-invocation facts outside the config tree and do not appear. The dump flags reject boot-only flags (`-p`, `--resume`, `--config-replace`) rather than silently ignoring them, and `--dump-default-config` takes no `--config`. -The Web and headless surfaces boot `base.cordis.yml` plus `web.cordis.yml`, then apply `$DSH_HOME/config.yaml`; an explicit `--config ` replaces that personal overlay. Both surfaces otherwise share the same composition: both tell the coding agent its resolved model and session working directory, treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, opt into first-message model titles, use the same bounded transient model-request retry policy as the TUI, and mount a disposable in-memory SQLite content-index service. Web additionally names the DeepSeek Harness Web GUI as the interaction surface, this checkout as its own source location, and the process's canonical local URL and mode in both the prompt and managed `$DSH_WEB_URL`/`$DSH_WEB_MODE`; references such as “this page” therefore identify the GUI without claiming access to implicit DOM, route, or screenshot state. In production mode the host reads rebuilt frontend dist and client bundles on the next request, so refreshing the existing URL updates that GUI without replacing its process. `dsh web --dev` mounts the client-plugin HMR receiver, but no-refresh updates additionally require `pnpm run dev:web` in the same checkout to watch and rebuild plugin bundles; shell and ordinary package changes still require a rebuild and page refresh. Bare `apps/web` Vite serving fails before listening because it cannot inject `window.__DSH_BOOT__`. The index service is ACTIVE at boot, while its `node:sqlite` module and database handle open only on the first content search. This keeps Node 22 startup output free of SQLite's experimental warning before search is used; the first actual search may still emit the runtime warning. Each service instance owns its database, so parallel invocations neither share unsupported SQLite state nor leave derived index files behind, and the first search lazily reconciles live and persisted logs. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`). +The Web and headless surfaces boot `base.cordis.yml` plus `web.cordis.yml`, then any `--config ` overlay. Both surfaces otherwise share the same composition: both tell the coding agent its resolved model and session working directory, treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, opt into first-message model titles, use the same bounded transient model-request retry policy as the TUI, and mount a disposable in-memory SQLite content-index service. Web additionally names the DeepSeek Harness Web GUI as the interaction surface, this checkout as its own source location, and the process's canonical local URL and mode in both the prompt and managed `$DSH_WEB_URL`/`$DSH_WEB_MODE`; references such as “this page” therefore identify the GUI without claiming access to implicit DOM, route, or screenshot state. In production mode the host reads rebuilt frontend dist and client bundles on the next request, so refreshing the existing URL updates that GUI without replacing its process. `dsh web --dev` mounts the client-plugin HMR receiver, but no-refresh updates additionally require `pnpm run dev:web` in the same checkout to watch and rebuild plugin bundles; shell and ordinary package changes still require a rebuild and page refresh. Bare `apps/web` Vite serving fails before listening because it cannot inject `window.__DSH_BOOT__`. The index service is ACTIVE at boot, while its `node:sqlite` module and database handle open only on the first content search. This keeps Node 22 startup output free of SQLite's experimental warning before search is used; the first actual search may still emit the runtime warning. Each service instance owns its database, so parallel invocations neither share unsupported SQLite state nor leave derived index files behind, and the first search lazily reconciles live and persisted logs. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`). The shared composition defaults new TUI, Web, and headless sessions to the `workspace-write` permission preset (`workspace-write` file mode plus `ask` approval policy). Sandbox-enforced bash and filesystem mutations may write only under the session workspace and platform temporary roots; reads, network access, and process visibility are not confined. The browser answers one-shot approval requests and exposes the Access picker; the TUI exposes `/permission`, but has no approval-request answerer, so an automatic wider retry there fails closed until the user deliberately changes the session preset. `DSH_PERMISSION_MODE` changes the process fallback, while a stored General-settings Permission value applies to later sessions without changing an open one. -All three surfaces consume `$DSH_HOME/config.yaml`; the TUI and Web apply valid edits live, while one-shot headless runs read it at startup. The shipped trees include an empty `repository-plugins` row, so a standalone user can add prepared GitHub Plugins without an SDK project or install command: +Every surface reads its `--config` overlay once at startup. The shipped trees include an empty `repository-plugins` row, so a standalone user can add prepared GitHub Plugins without an SDK project or install command, by naming an overlay such as `dsh --config ~/.dsh/plugins.yml`: ```yaml - id: repository-plugins @@ -53,7 +53,7 @@ pnpm run dsh web --config apps/cli/config/core-web.cordis.yml Every `dsh` surface — TUI, Web, and headless — reports session telemetry by default (the row lives in the shared `base.cordis.yml`): every session-log event streams as OTLP/HTTP log records to `https://harness-telemetry.deepseeksvc.com/v1/logs` on a 10-second batch cadence. `DSH_TELEMETRY_OTLP_URL` points the exporter at a different collector; setting `DSH_TELEMETRY_DISABLED` to ANY non-empty value — including `0` or `false` — disables the row before it loads (a privacy switch prefers off-by-mistake over on-by-mistake). No redaction rule is mounted in this composition yet: exported records are the raw captured copy, including message text, tool arguments and results, and the session's working-directory path. The deployment rulings live in the [web-telemetry-default-mount Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md). -MCP servers are not a shipped default, because a default would have to name one: `@deepseek-ai/dsh-mcp-client` mounts exactly one server per row and spawns it as a child process, outside `ctx.bash` and so outside the sandbox policy. The package is a runtime dependency of this CLI, so an installed `dsh` can mount your own servers from `$DSH_HOME/config.yaml` or a `--config` overlay without a source checkout: +MCP servers are not a shipped default, because a default would have to name one: `@deepseek-ai/dsh-mcp-client` mounts exactly one server per row and spawns it as a child process, outside `ctx.bash` and so outside the sandbox policy. The package is a runtime dependency of this CLI, so an installed `dsh` can mount your own servers from a `--config` overlay without a source checkout: ```yaml - insert: diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 16a7a4ec52..011cacb347 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -7,11 +7,11 @@ Argv 只会通过 [Commander](https://github.com/tj/commander.js) 适配器([` TUI 界面: -- 通过 [`dsh-app-boot`](../../packages/ui/app-boot/README.md) 启动 `base.cordis.yml` 与 `tui.cordis.yml`;`--config ` 应用一个补丁列表覆盖并替代个人覆盖,而 `--config-replace ` 将指定文件作为完整配置树启动; +- 通过 [`dsh-app-boot`](../../packages/ui/app-boot/README.md) 启动 `base.cordis.yml` 与 `tui.cordis.yml`;`--config ` 在该树之上应用一个补丁列表覆盖,而 `--config-replace ` 将指定文件作为完整配置树启动;每个会启动的界面都接受这两个标志; - 使用 `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)):`config.yaml` 修补已启动的树,而那里的 `.env` 是凭据 provider 自己的存储(绝不会被提升进环境,因此密钥始终可轮换)。环境优先级为环境中已有的值 > 项目 `.env`。已交付配置树中的 Cordis HMR 会持续应用 `config.yaml` 的变更;显式 `--config` 配置树会替代该个人覆盖,未包含 HMR 的配置树只在启动时读取该文件。 +- 只把 Harness home(`~/.dsh`)当作用户状态来读取(参见 [app-boot 的 Harness home](../../packages/ui/app-boot/README.md#the-harness-home)):`.env` 是用户环境层,`.credentials.yaml` 是凭据 provider 自己的存储,绝不会被提升进环境,因此密钥始终可轮换。环境优先级为环境中已有的值 > 项目 `.env` > 用户 `.env`。那里不会发现任何 composition 文件:overlay 只能通过 `--config` 抵达一次启动。 - 当 `DSH_HOME` 下不存在不可变确认标记时,通过已挂载的 TUI overlay 服务呈现[版本化首次运行欢迎页](../../.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md);只有 Enter 会创建该版本的标记,Escape、资源释放或进程退出仍保留展示资格。官方 DeepSeek 图标、响应式终端栅格图、所有 locale 共用的中文文案和通知版本均由静态本地文件持有;overlay 不会写入会话事件或模型上下文。 - 注册裸 `/compact`:agent 空闲时,即使未达到自动压力,也会摘要有效的较早历史;该命令拒绝参数,并只在独立替换标记对持久化后报告成功。压缩(compaction)期间提交的提示词保留其队列身份,并在该检查点之后启动;注入的上下文仍保持可见。 @@ -19,13 +19,13 @@ TUI 界面: `dsh upgrade` 是默认 TUI 界面之上的引导式全新会话入口:它在调用目录中创建一个全新会话,并以内置 `dsh-upgrade` skill 播种其首轮,效果等同于用户手动键入 `/skill:`。启动器将 skill 名称提供到启动上下文([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)),TUI 在聊天就绪后自动调用它。该命令除实验性门槛外不接受任何选项——`--config`、`-p`、`--resume` 都会明确报错——且仅在首次启动时播种,因此之后 `dsh --resume ` 恢复该会话时是普通 TUI 会话,不会重复注入。 -`dsh --dump-config` 和 `dsh web --dump-config` 把合成后的配置树——已交付的基础配置、界面覆盖层,以及 `--config` 或个人覆盖层,恰好是该界面启动时组装的那些层——以 YAML 打印到 stdout 后退出,不启动任何东西;`--dump-default-config` 止步于界面覆盖层,因此对两份输出做 diff 就能精确看出用户层改了什么。每段连续的行之前都有一条 `# ==` 注释,标明该段来自哪个文件以及被哪些层修补过(例如 `# == base.cordis.yml, patched by tui.cordis.yml`),因此输出既展示来源,又仍是一份可加载的文档。合成通过 include 自己的补丁算法和 YAML 方言(`@cordisjs/plugin-include` 的 `applyEntryPatches`/`entryListSchema`)完成,因此 dump 不可能与实际启动漂移;`!!js` 表达式原样打印、不求值,目标行不存在的补丁会连同其所在层报到 stderr,与 Loader 启动时的警告一致。由启动器持有的启动上下文值(会话身份、CLI 标志补丁)是每次调用的事实,位于配置树之外,不会出现。dump 标志会拒绝仅用于启动的标志(`-p`、`--resume`、`--config-replace`)而不是静默忽略它们,`--dump-default-config` 不接受 `--config`。 +`dsh --dump-config` 和 `dsh web --dump-config` 把合成后的配置树——已交付的基础配置、界面覆盖层,以及任何 `--config` 覆盖层,恰好是该界面启动时组装的那些层——以 YAML 打印到 stdout 后退出,不启动任何东西;`--dump-default-config` 止步于界面覆盖层,因此对两份输出做 diff 就能精确看出用户层改了什么。每段连续的行之前都有一条 `# ==` 注释,标明该段来自哪个文件以及被哪些层修补过(例如 `# == base.cordis.yml, patched by tui.cordis.yml`),因此输出既展示来源,又仍是一份可加载的文档。合成通过 include 自己的补丁算法和 YAML 方言(`@cordisjs/plugin-include` 的 `applyEntryPatches`/`entryListSchema`)完成,因此 dump 不可能与实际启动漂移;`!!js` 表达式原样打印、不求值,目标行不存在的补丁会连同其所在层报到 stderr,与 Loader 启动时的警告一致。由启动器持有的启动上下文值(会话身份、CLI 标志补丁)是每次调用的事实,位于配置树之外,不会出现。dump 标志会拒绝仅用于启动的标志(`-p`、`--resume`、`--config-replace`)而不是静默忽略它们,`--dump-default-config` 不接受 `--config`。 -Web 和无头界面启动 `base.cordis.yml` 与 `web.cordis.yml`,随后应用 `$DSH_HOME/config.yaml`;显式的 `--config ` 会替代该个人覆盖。除此之外,两者共享同一套组合:两者都会告知编码 agent 所用模型和会话工作目录,将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,选用首条消息模型标题,采用与 TUI 相同的有界暂时性模型请求重试策略,并挂载一个可丢弃的内存 SQLite 内容索引服务。Web 还会明确说明交互界面是 DeepSeek Harness Web GUI、当前 checkout 是自身源码位置,并在提示词及受管的 `$DSH_WEB_URL`/`$DSH_WEB_MODE` 中提供该进程的规范本地 URL 和模式;因此,「这个页面」等表述会指向该 GUI,但 agent 不会声称可以访问未显式提供的 DOM、路由或截图状态。在生产模式下,宿主会在下次请求时读取重新构建的前端 dist 和客户端 bundle,因此刷新现有 URL 即可更新该 GUI,无须替换其进程。`dsh web --dev` 会挂载客户端插件的 HMR(热模块替换)接收端,但要实现无刷新更新,还需在同一 checkout 中运行 `pnpm run dev:web`,以监视并重新构建插件 bundle;shell 和普通包(package)的更改仍需重新构建并刷新页面。直接使用裸 `apps/web` Vite 服务会在开始监听前失败,因为它无法注入 `window.__DSH_BOOT__`。索引服务在启动时处于 ACTIVE 状态,但其 `node:sqlite` 模块与数据库句柄分别要到首次内容搜索才会导入和打开。这样可使 Node 22 在尚未使用搜索时的启动输出不出现 SQLite 实验性警告;首次实际搜索仍可能发出运行时警告。每个服务实例独占自己的数据库,因此并行调用既不会共享不受支持的 SQLite 状态,也不会留下派生索引文件,首次搜索还会惰性对账实时日志与持久化日志。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。 +Web 和无头界面启动 `base.cordis.yml` 与 `web.cordis.yml`,随后应用任何 `--config ` 覆盖。除此之外,两者共享同一套组合:两者都会告知编码 agent 所用模型和会话工作目录,将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,选用首条消息模型标题,采用与 TUI 相同的有界暂时性模型请求重试策略,并挂载一个可丢弃的内存 SQLite 内容索引服务。Web 还会明确说明交互界面是 DeepSeek Harness Web GUI、当前 checkout 是自身源码位置,并在提示词及受管的 `$DSH_WEB_URL`/`$DSH_WEB_MODE` 中提供该进程的规范本地 URL 和模式;因此,「这个页面」等表述会指向该 GUI,但 agent 不会声称可以访问未显式提供的 DOM、路由或截图状态。在生产模式下,宿主会在下次请求时读取重新构建的前端 dist 和客户端 bundle,因此刷新现有 URL 即可更新该 GUI,无须替换其进程。`dsh web --dev` 会挂载客户端插件的 HMR(热模块替换)接收端,但要实现无刷新更新,还需在同一 checkout 中运行 `pnpm run dev:web`,以监视并重新构建插件 bundle;shell 和普通包(package)的更改仍需重新构建并刷新页面。直接使用裸 `apps/web` Vite 服务会在开始监听前失败,因为它无法注入 `window.__DSH_BOOT__`。索引服务在启动时处于 ACTIVE 状态,但其 `node:sqlite` 模块与数据库句柄分别要到首次内容搜索才会导入和打开。这样可使 Node 22 在尚未使用搜索时的启动输出不出现 SQLite 实验性警告;首次实际搜索仍可能发出运行时警告。每个服务实例独占自己的数据库,因此并行调用既不会共享不受支持的 SQLite 状态,也不会留下派生索引文件,首次搜索还会惰性对账实时日志与持久化日志。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。 共享组合把新建 TUI、Web 和无头会话的权限默认设为 `workspace-write` preset(`workspace-write` 文件模式加 `ask` 审批策略)。由沙箱强制约束的 bash 与文件系统修改只能写入会话工作区和平台临时根目录;读取、网络访问和进程可见性不受该策略约束。浏览器可以应答一次性审批请求,并提供 Access 选择器;TUI 提供 `/permission`,但没有审批请求应答者,因此自动请求更宽权限的重试会以拒绝方式关闭,直到用户主动更改会话 preset。`DSH_PERMISSION_MODE` 会更改进程回退值,而「通用」设置中已存储的「权限」值只适用于之后的会话,不会更改已打开的会话。 -三个界面都会使用 `$DSH_HOME/config.yaml`;TUI 和 Web 实时应用有效编辑,而一次性无头运行只在启动时读取。已交付的配置树包含一个空的 `repository-plugins` 配置项,因此独立用户无需 SDK 项目或安装命令,只需配置即可添加已准备的 GitHub 插件: +每个界面都只在启动时读取自己的 `--config` 覆盖。已交付的配置树包含一个空的 `repository-plugins` 配置项,因此独立用户无需 SDK 项目或安装命令,只要点名一个覆盖文件(例如 `dsh --config ~/.dsh/plugins.yml`)即可添加已准备的 GitHub 插件: ```yaml - id: repository-plugins @@ -53,7 +53,7 @@ pnpm run dsh web --config apps/cli/config/core-web.cordis.yml 每个 `dsh` 界面——TUI、Web 与无头——都默认上报会话遥测(该行位于共享的 `base.cordis.yml`):每条会话日志事件以 OTLP/HTTP 日志记录的形式、按 10 秒批处理节奏流向 `https://harness-telemetry.deepseeksvc.com/v1/logs`。`DSH_TELEMETRY_OTLP_URL` 可将 exporter 指向其他 collector;将 `DSH_TELEMETRY_DISABLED` 设为**任意非空值**——包括 `0` 或 `false`——都会在该行加载前将其关停(隐私开关取「宁可误关、不可误开」)。该组合当前未挂载任何脱敏规则:导出记录即原始捕获副本,包含消息正文、工具参数与结果、以及会话工作目录路径。部署口径见 [web-telemetry-default-mount Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md)。 -MCP 服务器不是交付默认值,因为默认值必须点名一台:`@deepseek-ai/dsh-mcp-client` 每一行只挂载一台服务器,并把它作为子进程 spawn,该进程不经 `ctx.bash`,因此也不受沙箱策略约束。该包是本 CLI 的运行时依赖,所以已安装的 `dsh` 无需源码检出即可从 `$DSH_HOME/config.yaml` 或 `--config` 覆盖层挂载你自己的服务器: +MCP 服务器不是交付默认值,因为默认值必须点名一台:`@deepseek-ai/dsh-mcp-client` 每一行只挂载一台服务器,并把它作为子进程 spawn,该进程不经 `ctx.bash`,因此也不受沙箱策略约束。该包是本 CLI 的运行时依赖,所以已安装的 `dsh` 无需源码检出即可从 `--config` 覆盖层挂载你自己的服务器: ```yaml - insert: diff --git a/apps/cli/config/base.cordis.yml b/apps/cli/config/base.cordis.yml index d46e103426..aea2f8934c 100644 --- a/apps/cli/config/base.cordis.yml +++ b/apps/cli/config/base.cordis.yml @@ -1,7 +1,7 @@ # The shared `dsh` core: every row both the TUI (`tui.cordis.yml`) and the web # surface (`web.cordis.yml`) mount identically. Neither surface includes the # other — each is a patch list applied over THIS file at one include level, so a -# surface overlay, a `--config` overlay, and the personal `~/.dsh/config.yaml` +# surface overlay and an explicit `--config` overlay # all address these rows by id. Patch lists stack in that order, last write # winning per row. # @@ -22,7 +22,7 @@ config: root: ['.'] -# `$DSH_HOME/config.yaml` replaces this row's config to select exact GitHub +# A `--config` overlay replaces this row's config to select exact GitHub # repository Plugin generations. The app registers the DSH-owned runtime even # when the list is empty so a later personal-config edit can load # transactionally; one-shot headless runs consume the startup value only. diff --git a/apps/cli/src/app-cli-entry.ts b/apps/cli/src/app-cli-entry.ts index ba3105c3ef..95776484d0 100644 --- a/apps/cli/src/app-cli-entry.ts +++ b/apps/cli/src/app-cli-entry.ts @@ -16,13 +16,7 @@ import { resolve } from 'node:path' import { Context } from 'cordis' import type { PatchOptions } from '@cordisjs/plugin-include' import yaml from 'js-yaml' -import { - boot, - installFailLoud, - loadOverlayPatches, - loadPersonalPatches, - watchPersonalPatches, -} from '@deepseek-ai/dsh-app-boot' +import { boot, installFailLoud, loadOverlayPatches } 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' @@ -117,16 +111,18 @@ export interface AppCLIEntryOptions { * fields on the same row. */ overlayPath: string - /** - * Optional explicit overlay applied after {@link overlayPath} and before - * this entry's own flag patches. When absent, the personal - * `$DSH_HOME/config.yaml` overlay is applied instead. - */ + /** Optional `--config` overlay applied after {@link overlayPath} and before this entry's own flag patches. */ extraOverlayPath?: string + /** + * Optional `--config-replace` tree: booted INSTEAD of {@link configPath}, + * {@link overlayPath}, {@link extraOverlayPath}, and every generated patch, + * so the caller's file is the whole composition. It must still supply the + * serving rows this entry needs — {@link run} rejects a settled tree with no + * `httpServer`. + */ + configReplacePath?: string /** Whether to append client-bundle HMR (the Web surface's prod/dev difference). */ dev: boolean - /** Whether `$DSH_HOME/config.yaml` remains live after the initial boot. */ - watchPersonalConfig: boolean /** --host when explicitly passed; undefined keeps the yml engineering default. */ host?: string /** @@ -176,8 +172,15 @@ export class AppCLIEntry { await this.bootTree() this.assertBoot() const port = this.ctx.get('httpServer')?.port - /* v8 ignore next -- the sweep above guarantees an ACTIVE webserver row */ - if (port === undefined) throw new Error('dsh: httpServer service missing after settled boot') + if (port === undefined) { + // The shipped tree always carries the webserver row, so this is only + // reachable through --config-replace: name the missing contract rather + // than report a bare missing service. + throw new Error( + `dsh: no httpServer after booting ${this.bootConfigPath()}; this surface serves over HTTP, so a` + + ' --config-replace tree must mount a webserver row', + ) + } return { ctx: this.ctx, port } } @@ -188,6 +191,16 @@ export class AppCLIEntry { */ private composePatches(): void { const rows = this.parseYmlRows() + if (this.options.configReplacePath !== undefined) { + // A replacement tree is the caller's whole composition: the generated + // patches target shipped row ids this file cannot assume exist, and a + // patch whose id is absent is a silent no-op rather than a diagnostic. + // Telemetry stays, judged against the tree actually booting, because a + // privacy switch that silently no-ops is worse than a loud one. + const replaceTelemetry = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, rows.has(TELEMETRY_ROW_ID)) + this.patches = replaceTelemetry === undefined ? [] : [replaceTelemetry] + return + } const overrides = new Map>() const put = (entryId: string, key: string, value: unknown): void => { const bag = overrides.get(entryId) ?? {} @@ -230,31 +243,26 @@ export class AppCLIEntry { // One include of the shared base with every overlay as a sibling patch // list: patches never cross an include boundary, so nesting them would // silently stop reaching base rows. The surface overlay applies first, then - // this entry's CLI-flag patches, which therefore win. - const compose = (overlay: PatchOptions[]): PatchOptions[] => [ - ...loadOverlayPatches('dsh', this.options.overlayPath), - ...overlay, - ...this.patches, - ] - // An explicit --config overlay REPLACES the personal overlay, so there is - // then no personal layer to keep live — the watcher is personal-only. - const watchPersonal = this.options.watchPersonalConfig && this.options.extraOverlayPath === undefined - const patches = compose( - this.options.extraOverlayPath === undefined - ? loadPersonalPatches('dsh') ?? [] - : loadOverlayPatches('dsh', this.options.extraOverlayPath), - ) - this.ctx = await boot('dsh', resolve(this.options.configPath), patches, async (ctx) => { + // any --config overlay, then this entry's CLI-flag patches, which win. + // --config-replace discards all three and boots the named file alone. + const patches = this.options.configReplacePath !== undefined + ? this.patches + : [ + ...loadOverlayPatches('dsh', this.options.overlayPath), + ...this.options.extraOverlayPath === undefined + ? [] + : loadOverlayPatches('dsh', this.options.extraOverlayPath), + ...this.patches, + ] + this.ctx = await boot('dsh', resolve(this.bootConfigPath()), patches, async (ctx) => { await this.options.prepare?.(ctx) - // Config-only HMR for the personal overlay: module reload stays off for - // this surface (web.cordis.yml disables the shared `hmr` row until its - // reload lifecycle is tested), so this row watches no module roots. - if (watchPersonal) await ctx.loader.create({ name: '@cordisjs/plugin-hmr', config: { root: [] } }) if (this.options.dev) await ctx.loader.create({ name: '@deepseek-ai/dsh-client-hmr' }) }) - if (watchPersonal) { - await watchPersonalPatches(this.ctx, { binName: 'dsh', compose }) - } + } + + /** The file the Loader includes: the replacement tree when named, otherwise the shared base. */ + private bootConfigPath(): string { + return this.options.configReplacePath ?? this.options.configPath } /** Install the diagnostic for plugin rejections that happen after settled boot. */ @@ -270,6 +278,17 @@ export class AppCLIEntry { */ private parseYmlRows(): Map { const rows = new Map() + // A replacement tree stands alone, so only its own rows are indexed — + // the telemetry-row check must judge the tree that actually boots. + if (this.options.configReplacePath !== undefined) { + for (const row of this.parseRowList(this.options.configReplacePath)) { + if (typeof row.id === 'string') rows.set(row.id, row) + for (const inserted of row.insert ?? []) { + if (typeof inserted.id === 'string') rows.set(inserted.id, inserted) + } + } + return rows + } const files = [this.options.configPath, this.options.overlayPath] if (this.options.extraOverlayPath !== undefined) files.push(this.options.extraOverlayPath) for (const file of files) { diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index 19bc58ccd4..e2ef70bd10 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -16,8 +16,8 @@ import { Command, CommanderError } from 'commander' /** * Interactive TUI: the default mode. `--config` applies an overlay over the - * shipped composition in place of the personal one, `--config-replace` boots a - * file as the whole tree instead, and `--resume ` rehydrates a session. + * shipped composition, `--config-replace` boots a file as the whole tree + * instead, and `--resume ` rehydrates a session. */ interface TuiInvocation { mode: 'tui' @@ -28,40 +28,49 @@ interface TuiInvocation { /** * Print the composed config tree and exit, without booting: `--dump-config` - * composes the shipped base, the surface overlay, and the `--config` or - * personal overlay — exactly the layers that surface would boot; - * `--dump-default-config` stops at the surface overlay (the shipped tree, no - * user layer). + * composes the shipped base, the surface overlay, and any `--config` overlay — + * exactly the layers that surface would boot; `--dump-default-config` stops at + * the surface overlay (the shipped tree, no user layer). */ interface DumpConfigInvocation { mode: 'dump-config' surface: 'tui' | 'web' - /** Omit the `--config`/personal layer and print only the shipped composition. */ + /** Omit the `--config` layer and print only the shipped composition. */ defaultOnly: boolean - /** The `--config` overlay to compose instead of the personal one. */ + /** The `--config` overlay to compose over the shipped tree. */ config?: string } -/** Headless one-shot: `dsh -p "task"`. */ +/** + * Headless one-shot: `dsh -p "task"`. `--config` and `--config-replace` mean + * exactly what they mean for the TUI, so an automated run can name its + * composition instead of depending on whatever the machine happens to hold. + */ interface HeadlessInvocation { mode: 'headless' prompt: string + config?: string + configReplace?: string } -/** Interactive fresh TUI over this harness checkout; accepts no default-surface options, only the experimental gate. */ +/** Interactive fresh TUI over this harness checkout; takes the composition flags and the experimental gate. */ interface MetaInvocation { mode: 'meta' + config?: string + configReplace?: string } /** * Guided fresh-session entry: `dsh upgrade` seeds the first turn - * with the `dsh-upgrade` skill. It always mints a - * fresh session in the invoking directory and takes no options beyond the - * experimental gate — `--resume`, `--config`, and `-p` are rejected as - * mistyped, so there is nothing to carry. + * with the `dsh-upgrade` skill. It always mints a fresh session in the + * invoking directory, so `--resume` and `-p` are rejected as mistyped; the + * composition flags are accepted because the update runs against whatever + * tree the caller names. */ interface SkillSessionInvocation { mode: 'upgrade' + config?: string + configReplace?: string } /** @@ -184,9 +193,9 @@ Examples: // subcommand without a positional collision. .option('-p, --prompt ', 'answer this task without the interactive UI, then exit') .option('--resume ', 'continue a past session by id') - .option('--config ', 'apply this overlay of loader patches instead of the personal one') - .option('--config-replace ', 'boot this file as the entire tree, ignoring the shipped and personal configuration') - .option('--dump-config', 'print the composed config tree (base + surface + --config/personal overlay) and exit') + .option('--config ', 'apply this overlay of loader patches over the shipped configuration') + .option('--config-replace ', 'boot this file as the entire tree, ignoring the shipped configuration') + .option('--dump-config', 'print the composed config tree (base + surface + --config overlay) and exit') .option('--dump-default-config', 'print the shipped config tree (base + surface overlay, no user layer) and exit') .action((options: { config?: string @@ -208,23 +217,24 @@ Examples: } if (options.prompt !== undefined) { // A headless prompt owns the invocation; an empty task has nothing to - // run, and --config/--resume are TUI inputs that must not silently - // vanish from a headless run. + // run, and --resume is a TUI input that must not silently vanish from + // a one-shot run. The composition flags DO apply: naming a tree is how + // an automated run pins its composition. if (options.prompt === '') program.error('error: --prompt needs a task') - if (options.config !== undefined || options.configReplace !== undefined || options.resume !== undefined) { - program.error('error: --prompt takes no --config, --config-replace, or --resume') + if (options.resume !== undefined) program.error('error: --prompt takes no --resume') + assertOneConfigFlag(options) + resolved = { + mode: 'headless', + prompt: options.prompt, + ...options.config !== undefined && { config: options.config }, + ...options.configReplace !== undefined && { configReplace: options.configReplace }, } - resolved = { mode: 'headless', prompt: options.prompt } return } // An empty --resume= id would silently start a fresh session downstream // (agent-loop treats '' as no-resume), so a mistyped resume must fail loud. if (options.resume === '') program.error('error: --resume needs a session id') - // The two config flags are mutually exclusive: one layers over the shipped - // tree, the other discards it, so accepting both would silently drop one. - if (options.config !== undefined && options.configReplace !== undefined) { - program.error('error: --config and --config-replace are mutually exclusive') - } + assertOneConfigFlag(options) resolved = { mode: 'tui', ...options.config !== undefined && { config: options.config }, @@ -233,10 +243,27 @@ Examples: } }) + /** + * The two config flags are mutually exclusive on every surface that takes + * them: one layers over the shipped tree, the other discards it, so + * accepting both would silently drop one. + * @param options - the parsed options of the surface being resolved. + */ + function assertOneConfigFlag(options: { config?: string; configReplace?: string }): void { + if (options.config !== undefined && options.configReplace !== undefined) { + program.error('error: --config and --config-replace are mutually exclusive') + } + } + + /** The composition flags every booting surface registers, in one place so their help text cannot drift. */ + const withConfigFlags = (command: Command): Command => command + .option('--config ', 'apply this overlay of loader patches over the shipped configuration') + .option('--config-replace ', 'boot this file as the entire tree, ignoring the shipped configuration') + // Commander parses the parent (default-surface) options on either side of a - // subcommand into `program.opts()`. For a subcommand that shares none of them, - // a leaked config/prompt/resume option is a mistyped invocation that must fail - // loud rather than silently run and drop the input. + // subcommand into `program.opts()`. A subcommand takes its own flags after + // its own name, so a leaked parent config/prompt/resume option is a mistyped + // invocation that must fail loud rather than silently run and drop the input. const rejectParentOptions = (command: string): void => { const parent = program.opts<{ config?: string @@ -267,14 +294,18 @@ Examples: // come last. `upgrade` is a guided fresh-session entry: beyond the // experimental gate it takes no options and always mints a fresh session, // so nothing is left to carry. - program - .command('upgrade') + withConfigFlags(program.command('upgrade')) .description('update this dsh installation to the latest version (experimental)') .option('--experimental', 'acknowledge this subcommand is experimental') - .action((options: { experimental?: boolean }) => { + .action((options: { experimental?: boolean; config?: string; configReplace?: string }) => { rejectParentOptions('upgrade') requireExperimental('upgrade', options.experimental) - resolved = { mode: 'upgrade' } + assertOneConfigFlag(options) + resolved = { + mode: 'upgrade', + ...options.config !== undefined && { config: options.config }, + ...options.configReplace !== undefined && { configReplace: options.configReplace }, + } }) // Host and port name no default: the CLI passes neither through when the flag @@ -288,7 +319,7 @@ Examples: .option('--dev', 'mount the client-plugin HMR receiver (run pnpm run dev:web separately to rebuild bundles)') .option('--workspace-root ', 'parent directory for workspaces created from the browser UI') .option('--trusted-host ', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)') - .option('--dump-config', 'print the composed config tree (base + web + --config/personal overlay) and exit') + .option('--dump-config', 'print the composed config tree (base + web + --config overlay) and exit') .option('--dump-default-config', 'print the shipped config tree (base + web overlay, no user layer) and exit') .action((options: WebOptions) => { rejectParentOptions('web') @@ -300,14 +331,18 @@ Examples: resolved = resolveWeb(options) }) - program - .command('meta') + withConfigFlags(program.command('meta')) .description('work on the dsh source that runs this command, from any directory (experimental)') .option('--experimental', 'acknowledge this subcommand is experimental') - .action((options: { experimental?: boolean }) => { + .action((options: { experimental?: boolean; config?: string; configReplace?: string }) => { rejectParentOptions('meta') requireExperimental('meta', options.experimental) - resolved = { mode: 'meta' } + assertOneConfigFlag(options) + resolved = { + mode: 'meta', + ...options.config !== undefined && { config: options.config }, + ...options.configReplace !== undefined && { configReplace: options.configReplace }, + } }) try { diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts index dd5642de10..bdef3205b9 100644 --- a/apps/cli/src/bin.ts +++ b/apps/cli/src/bin.ts @@ -36,7 +36,7 @@ switch (invocation.mode) { } case 'headless': { const { runHeadless } = await import('./headless.ts') - await runHeadless(invocation.prompt) + await runHeadless(invocation.prompt, invocation.config, invocation.configReplace) break } case 'tui': { @@ -51,12 +51,12 @@ switch (invocation.mode) { } case 'meta': { const { runTui, SOURCE_ROOT } = await import('./tui.ts') - await runTui(undefined, undefined, SOURCE_ROOT) + await runTui(invocation.config, undefined, SOURCE_ROOT, undefined, invocation.configReplace) break } case 'upgrade': { const { runTui } = await import('./tui.ts') - await runTui(undefined, undefined, undefined, `dsh-${invocation.mode}`) + await runTui(invocation.config, undefined, undefined, `dsh-${invocation.mode}`, invocation.configReplace) break } default: diff --git a/apps/cli/src/dump-config.ts b/apps/cli/src/dump-config.ts index 39a87c2dc8..80022a0efb 100644 --- a/apps/cli/src/dump-config.ts +++ b/apps/cli/src/dump-config.ts @@ -1,7 +1,7 @@ /** * `dsh --dump-config` / `dsh web --dump-config` — print the composed config * tree without booting: the shipped base, the surface overlay, and (unless - * `--dump-default-config`) the `--config` or personal overlay, composed + * `--dump-default-config`) any `--config` overlay, composed * through the include's own patch algorithm so the printed tree is exactly * what that surface would mount. `!!js` expressions print verbatim, * unevaluated — the dump shows composition, not one process's environment. @@ -10,16 +10,13 @@ * @module @deepseek-ai/dsh/dump-config */ -import { basename, join } from 'node:path' +import { basename } from 'node:path' import { fileURLToPath } from 'node:url' import { loadOverlayPatches, - loadPersonalPatches, - PERSONAL_CONFIG_FILENAME, renderConfigDump, type ConfigDumpLayer, } from '@deepseek-ai/dsh-app-boot' -import { resolveDshHome } from '@deepseek-ai/dsh-paths' const NAME = 'dsh' @@ -36,25 +33,17 @@ const SURFACE_OVERLAYS = { * separator naming the file each section of rows comes from (and the layers * that patched it). * @param surface - which surface overlay to compose over the shared base. - * @param defaultOnly - stop at the surface overlay (no `--config`/personal layer). - * @param config - the `--config` overlay path composed instead of the personal - * one, or `undefined` to use `$DSH_HOME/config.yaml`. + * @param defaultOnly - stop at the surface overlay (no `--config` layer). + * @param config - the `--config` overlay path to compose over the shipped + * tree, or `undefined` for the shipped composition alone. */ export function runDumpConfig(surface: 'tui' | 'web', defaultOnly: boolean, config?: string): void { const overlay = SURFACE_OVERLAYS[surface] const layers: ConfigDumpLayer[] = [ { label: basename(overlay), patches: loadOverlayPatches(NAME, overlay) }, ] - if (!defaultOnly) { - if (config === undefined) { - const personal = loadPersonalPatches(NAME) - // The personal file may be absent; the shipped layers still print. - if (personal !== undefined) { - layers.push({ label: join(resolveDshHome(), PERSONAL_CONFIG_FILENAME), patches: personal }) - } - } else { - layers.push({ label: config, patches: loadOverlayPatches(NAME, config) }) - } + if (!defaultOnly && config !== undefined) { + layers.push({ label: config, patches: loadOverlayPatches(NAME, config) }) } process.stdout.write(renderConfigDump(NAME, BASE_CONFIG, layers)) } diff --git a/apps/cli/src/headless.ts b/apps/cli/src/headless.ts index e41bc03c6c..5864604e05 100644 --- a/apps/cli/src/headless.ts +++ b/apps/cli/src/headless.ts @@ -9,6 +9,7 @@ */ import { fileURLToPath } from 'node:url' +import { resolveConfigPath } from '@deepseek-ai/dsh-app-boot' import { InProcessApiClient, toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy' import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api' import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' @@ -71,14 +72,19 @@ async function consumeUntilTurnEnd(frames: AsyncIterable>, * is the non-empty prompt the argument adapter parsed from `-p`/`--prompt` * (the adapter rejects an empty task, so no guard is needed here). * @param task - the prompt text for the single turn. + * @param config - a `--config` overlay applied over the shipped composition, or `undefined`. + * @param configReplace - a `--config-replace` tree booted instead of the + * shipped composition, or `undefined`. It must mount a webserver row: this + * surface reaches its own agent over the same HTTP gateway the browser uses. */ -export async function runHeadless(task: string): Promise { +export async function runHeadless(task: string, config?: string, configReplace?: string): Promise { // A missing DEEPSEEK_API_KEY throws here (plugin load is fail-loud, uncaught by design). const entry = new AppCLIEntry({ configPath: fileURLToPath(new URL('../config/base.cordis.yml', import.meta.url)), overlayPath: fileURLToPath(new URL('../config/web.cordis.yml', import.meta.url)), + ...config !== undefined && { extraOverlayPath: resolveConfigPath(config, undefined) }, + ...configReplace !== undefined && { configReplacePath: resolveConfigPath(configReplace, undefined) }, dev: false, - watchPersonalConfig: false, port: 0, }) const { ctx, port } = await entry.run() diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index f91ea05c4e..20981dc068 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -1,9 +1,9 @@ /** * `dsh` default surface — the interactive TUI coding agent. Boots the shipped - * shared base and TUI overlay, followed by either `--config` or the personal overlay - * from the Harness home (`~/.dsh`): its `.env` fills environment gaps (precedence: - * ambient environment, then the invoking directory's `.env`, then the personal one) - * and its `config.yaml` patches the booted tree. The workspace is the invoking + * shared base and TUI overlay, followed by any `--config` overlay. The Harness + * home (`~/.dsh`) contributes the user environment layer only: its `.env` fills + * environment gaps (precedence: ambient environment, then the invoking + * directory's `.env`, then the user one). The workspace is the invoking * directory: the session cwd, relative paths, and workspace instructions resolve * from it, so `dsh` acts on whatever project it is launched in. Session storage * is the exception — it lives under the Harness home so `/resume` reaches every @@ -26,9 +26,7 @@ import { boot, installFailLoud, loadOverlayPatches, - loadPersonalPatches, resolveConfigPath, - watchPersonalPatches, } from '@deepseek-ai/dsh-app-boot' import { resolveDshHome } from '@deepseek-ai/dsh-paths' import type { PatchOptions } from '@cordisjs/plugin-include' @@ -77,13 +75,12 @@ const SESSION_QUERY_DB = `session-query-${String(process.pid)}-${randomUUID()}.d export const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) /* v8 ignore start -- composition over the unit-tested dsh-app-boot helpers; - the CLI PTY smoke drives this path end to end, personal overlay included */ + the CLI PTY smoke drives this path end to end, --config overlay included */ /** * Run the interactive TUI from the invoking directory. * @param config - an overlay patch list applied over the shared base and the - * TUI overlay, REPLACING the personal `~/.dsh/config.yaml` so a named tree never - * inherits the user's route, or `undefined` to use the personal overlay; - * already parsed from `--config`. + * TUI overlay, or `undefined` for the shipped composition alone; already + * parsed from `--config`. * @param resumeSessionId - a persisted session id to resume, or `undefined` to * mint a fresh one; already parsed and non-empty-validated from `--resume`. * Either way the resulting identity reaches the booted app through @@ -95,9 +92,9 @@ export const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) * first turn, or `undefined`. Set only by `dsh upgrade` and * ignored on a resume, so it never re-fires; reaches the app through * {@link INITIAL_SKILL_KEY}. - * @param configReplace - a config path to boot as the ENTIRE tree, bypassing the - * shared base, the TUI overlay, and the personal overlay alike, or `undefined` - * to compose them; already parsed from `--config-replace`. + * @param configReplace - a config path to boot as the ENTIRE tree, bypassing + * the shared base and the TUI overlay alike, or `undefined` to compose them; + * already parsed from `--config-replace`. */ export async function runTui( config: string | undefined, @@ -202,10 +199,8 @@ export async function runTui( // patch list: patches never cross an include boundary, so stacking these as // nested includes would silently stop reaching base rows. Later lists win. // - // `--config` REPLACES the personal overlay rather than layering under it: an - // explicitly named tree must not inherit `~/.dsh/config.yaml`'s route, or a - // demo or test config would silently run on the user's provider and model. - // `--config-replace` additionally discards the base and the surface overlay. + // `--config` layers over the shipped base and TUI overlay; `--config-replace` + // discards both and boots the named file alone. const replaceTree = configReplace !== undefined const bootConfig = resolvedConfigReplace === undefined ? BASE_CONFIG : resolveConfigPath(resolvedConfigReplace, undefined) // Same opt-out semantics as the web surface (resolveTelemetryPatch: any @@ -214,16 +209,13 @@ export async function runTui( // presence is checked against the tree actually booting, so a // --config-replace tree is judged on its own rows, not the shipped base's. const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, configHasTelemetryRow(bootConfig)) - const composePatches = (personalPatches: PatchOptions[]): PatchOptions[] => [ + const patches: PatchOptions[] = [ ...replaceTree ? [] : [ ...loadOverlayPatches(NAME, TUI_OVERLAY), - ...resolvedConfig === undefined - ? personalPatches - : loadOverlayPatches(NAME, resolveConfigPath(resolvedConfig, undefined)), + ...resolvedConfig === undefined ? [] : loadOverlayPatches(NAME, resolveConfigPath(resolvedConfig, undefined)), ], ...telemetryPatch === undefined ? [] : [telemetryPatch], ] - const patches = composePatches(loadPersonalPatches(NAME) ?? []) const queryIndexPath = join(tmpdir(), SESSION_QUERY_DB) const ctx = await boot( NAME, @@ -243,8 +235,8 @@ export async function runTui( // the Harness home across every cwd, so /resume sees every workspace. // The bundle treats the slot as opaque. // The agent-loop row reads this to bind `main`, and the tui row reads the - // same id, so a personal overlay repointing the model route cannot drop - // the session identity or desynchronise the two. + // same id, so an overlay repointing the model route cannot drop the + // session identity or desynchronise the two. hostCtx.provide(CONFIGURED_AGENT_IDENTITIES_KEY, { [MAIN_AGENT_ID]: identity }) // The query database is a disposable derived index with single-process // ownership. Keep it process-local while it indexes the shared logs. @@ -264,14 +256,6 @@ export async function runTui( } }, ) - // The shipped tree includes HMR and keeps personal config live. An explicit - // --config tree replaces the personal overlay (so there is nothing to keep - // live), and a --config-replace or HMR-less tree remains a valid composition - // that still receives the startup overlay but deliberately has no hidden - // watcher. - if (resolvedConfig === undefined && !replaceTree && ctx.get('hmr') !== undefined) { - await watchPersonalPatches(ctx, { binName: NAME, compose: composePatches }) - } app.current = ctx addHarnessSourceSection(ctx, SOURCE_ROOT) if (showFirstRunWelcome) { diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index 4fbfba4d8d..a3dc446706 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -91,7 +91,7 @@ export function prepareWebRuntimeContext(ctx: Context, sourceRoot: string, mode: * @param workspaceRoot - parent directory for name-created workspaces, or `undefined` for the gateway's cwd fallback. * @param trustedHosts - extra authorities for the /api browser-trust fence, or `undefined` for the derived LAN literals alone. * @param config - an overlay of loader patches applied over the shipped web - * composition instead of `$DSH_HOME/config.yaml`, or `undefined` to use the + * composition, or `undefined` to boot the * personal overlay; already parsed from `--config`. */ export async function runWeb( @@ -109,7 +109,6 @@ export async function runWeb( ...config !== undefined && { extraOverlayPath: resolveConfigPath(config, undefined) }, dev, prepare: (ctx) => { prepareWebRuntimeContext(ctx, SOURCE_ROOT, mode) }, - watchPersonalConfig: true, ...host !== undefined && { host }, ...port !== undefined && { port }, ...workspaceRoot !== undefined && { workspaceRoot }, diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts index 5b0e76323d..a9f7d228bf 100644 --- a/apps/cli/tests/args.spec.ts +++ b/apps/cli/tests/args.spec.ts @@ -30,6 +30,17 @@ describe('parseDshArgs', () => { expect(parse(['--config-replace', 'tree.yml'])).toEqual({ mode: 'tui', configReplace: 'tree.yml' }) expect(parse(['--resume', 'sess', '--config', 'app.yml'])).toEqual({ mode: 'tui', config: 'app.yml', resume: 'sess' }) expect(parse(['-p', 'do the thing'])).toEqual({ mode: 'headless', prompt: 'do the thing' }) + // Every booting surface takes the composition flags: with the personal + // overlay gone, naming a tree is the only way to compose one, so a + // surface that could not name one would have no composition path at all. + expect(parse(['-p', 'task', '--config', 'c.yml'])) + .toEqual({ mode: 'headless', prompt: 'task', config: 'c.yml' }) + expect(parse(['-p', 'task', '--config-replace', 'tree.yml'])) + .toEqual({ mode: 'headless', prompt: 'task', configReplace: 'tree.yml' }) + expect(parse(['meta', '--experimental', '--config', 'c.yml'])) + .toEqual({ mode: 'meta', config: 'c.yml' }) + expect(parse(['upgrade', '--experimental', '--config-replace', 'tree.yml'])) + .toEqual({ mode: 'upgrade', configReplace: 'tree.yml' }) // Experimental subcommands run under the per-invocation flag or the env opt-in. expect(parse(['meta', '--experimental'])).toEqual({ mode: 'meta' }) expect(parse(['meta'], true)).toEqual({ mode: 'meta' }) @@ -77,9 +88,8 @@ describe('parseDshArgs', () => { // schema at boot, not here.) expect(exitCode(['--resume='])).toBe(1) expect(exitCode(['-p', ''])).toBe(1) - expect(exitCode(['-p', 'x', '--config', 'c.yml'])).toBe(1) - expect(exitCode(['-p', 'x', '--config-replace', 'tree.yml'])).toBe(1) expect(exitCode(['--config', 'c.yml', '--config-replace', 'tree.yml'])).toBe(1) + expect(exitCode(['-p', 'x', '--config', 'c.yml', '--config-replace', 'tree.yml'])).toBe(1) expect(exitCode(['-p', 'x', '--resume', 's'])).toBe(1) expect(exitCode(['--bogus'])).toBe(1) expect(exitCode(['bogus-positional'])).toBe(1) @@ -91,16 +101,14 @@ describe('parseDshArgs', () => { expect(exitCode(['--config-replace', 'tree.yml', 'web'])).toBe(1) // Same rule for each subcommand that shares no option with the default // surface, so a leaked flag is a typo, not something to ignore. - // `meta` fixes its own config tree and always starts fresh, - // so every default-surface option is rejected. + // `meta` always starts fresh, so the session options are rejected; the + // composition flags are its own and only their combination is rejected. expect(exitCode(['meta', '--experimental', '--resume', 's'])).toBe(1) - expect(exitCode(['meta', '--experimental', '--config', 'c.yml'])).toBe(1) - expect(exitCode(['meta', '--experimental', '--config-replace', 'tree.yml'])).toBe(1) expect(exitCode(['meta', '--experimental', '-p', 'task'])).toBe(1) - // `upgrade` takes no options beyond the gate: any leaked default-surface - // flag is a mistyped invocation, not a silently-dropped input. + expect(exitCode(['meta', '--experimental', '--config', 'c.yml', '--config-replace', 't.yml'])).toBe(1) + // `upgrade` always mints a fresh session, so `--resume` and a leaked + // parent flag are mistyped invocations; its own composition flags are not. expect(exitCode(['upgrade', '--experimental', '--resume', 's'])).toBe(1) - expect(exitCode(['upgrade', '--experimental', '--config', 'c.yml'])).toBe(1) expect(exitCode(['-p', 'task', 'upgrade', '--experimental'])).toBe(1) // The pre-release command names have no compatibility aliases. expect(exitCode(['experimental-meta'])).toBe(1) diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index 3592d438dd..67bccd8048 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -107,8 +107,9 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', expect(stdout).toContain('# == tui.cordis.yml') }, 30_000) - it('layers the personal overlay in --dump-config and reports an unmatched patch on stderr', async () => { - writeFileSync(join(home, 'config.yaml'), [ + it('layers a --config overlay in --dump-config and reports an unmatched patch on stderr', async () => { + const overlay = join(home, 'overlay.yml') + writeFileSync(overlay, [ '- id: agent-loop', ' config:', ' agents:', @@ -120,16 +121,19 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', ' value: 1', '', ].join('\n')) - const { stdout, code, stderr } = await runBuiltBin(['--dump-config'], { DSH_HOME: home }) + const { stdout, code, stderr } = await runBuiltBin(['--dump-config', '--config', overlay], { DSH_HOME: home }) expect(code).toBe(0) expect(stdout).toContain('provider: custom-provider') expect(stdout).not.toContain('model: deepseek-v4-pro') - // The personal layer appears in the patched row's provenance and the + // The named layer appears in the patched row's provenance and the // skipped-patch warning carries its label. - expect(stdout).toContain(`patched by tui.cordis.yml, ${join(home, 'config.yaml')}`) + expect(stdout).toContain(`patched by tui.cordis.yml, ${overlay}`) expect(stderr).toContain('patch: entry "only-on-web" not found') - // The shipped view ignores the personal overlay entirely. + // An unnamed dump composes the shipped tree only: a file sitting in the + // Harness home is not a layer any more. + const unnamed = await runBuiltBin(['--dump-config'], { DSH_HOME: home }) + expect(unnamed.stdout).not.toContain('custom-provider') const shipped = await runBuiltBin(['--dump-default-config'], { DSH_HOME: home }) expect(shipped.stdout).not.toContain('custom-provider') expect(shipped.stdout).toContain('model: deepseek-v4-pro') diff --git a/apps/cli/tests/tui-keyless-smoke.e2e.ts b/apps/cli/tests/tui-keyless-smoke.e2e.ts index ade38a0e9c..2894d0a1ca 100644 --- a/apps/cli/tests/tui-keyless-smoke.e2e.ts +++ b/apps/cli/tests/tui-keyless-smoke.e2e.ts @@ -40,7 +40,7 @@ const PTY_SMOKE_TEST_TIMEOUT_MS = process.env.DSH_EXAMPLE_MODE === 'lib' : LOADER_SMOKE_TEST_TIMEOUT_MS /** - * Seed the isolated process workspace: ordinary files land in `cwd`, personal + * Seed the isolated process workspace: ordinary files land in `cwd`, harness * files in the Harness home (`.dsh`), and skill bundles under the agents * home's `skills/` root — the same trees `$DSH_HOME` / * `$DSH_AGENTS_HOME` point the child at. @@ -48,7 +48,7 @@ const PTY_SMOKE_TEST_TIMEOUT_MS = process.env.DSH_EXAMPLE_MODE === 'lib' function seedWorkspace( files: { workspace?: Record - personal?: Record + harnessHome?: Record skills?: Record }, ): (cwd: string) => Promise { @@ -58,7 +58,7 @@ function seedWorkspace( await mkdir(dirname(file), { recursive: true }) await writeFile(file, content) } - for (const [name, content] of Object.entries(files.personal ?? {})) { + for (const [name, content] of Object.entries(files.harnessHome ?? {})) { const file = join(cwd, '.dsh', name) await mkdir(dirname(file), { recursive: true }) await writeFile(file, content) @@ -652,7 +652,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { expect(output).toContain('Preserve restored state') }, PTY_SMOKE_TEST_TIMEOUT_MS) - it('boots the shipped default config with no arguments and no personal overlay', async () => { + it('boots the shipped default config with no arguments and no overlay', async () => { const output = await smoke({ label: 'dsh default boot', tempDirPrefix: 'dsh-default-boot-', @@ -667,9 +667,9 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { expect(output).toContain('\u001B[?2004l') }, PTY_SMOKE_TEST_TIMEOUT_MS) - it('applies the personal overlay: config.yaml patches an overlay-inserted row, and both .env layers feed its !!js with the project one winning', async () => { - // The whole personal-config chain in one boot, plus the environment - // layering underneath it. config.yaml patches the `tui` row — a row the + it('applies a --config overlay: it patches an overlay-inserted row, and both .env layers feed its !!js with the project one winning', async () => { + // The whole explicit-overlay chain in one boot, plus the environment + // layering underneath it. The named file 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 `!!js` expression // renders both halves of the layering in one line: `DSH_LAYER_WELCOME` is @@ -678,13 +678,13 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { // arrive. Credentials are not part of this: they live in // `.credentials.yaml`, which is never hoisted into `process.env`. const output = await smoke({ - label: 'dsh personal overlay', - tempDirPrefix: 'dsh-personal-overlay-', + label: 'dsh explicit overlay', + tempDirPrefix: 'dsh-explicit-overlay-', binScript: dshBinScript, - configArgs: [], + configArgs: ['--config', '.dsh/config.yaml'], prepare: seedWorkspace({ workspace: { '.env': 'DSH_LAYER_WELCOME=PROJECT WINS.\n' }, - personal: { + harnessHome: { '.env': 'DSH_LAYER_WELCOME=USER LAYER LOST.\nDSH_USER_ONLY=USER LAYER LOADED.\n', 'config.yaml': [ '- id: workspace-context', @@ -705,7 +705,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { expect(output).toContain('\u001B[?2004l') }, PTY_SMOKE_TEST_TIMEOUT_MS) - it('loads a cached repository Plugin from personal config alone', async () => { + it('loads a cached repository Plugin from a --config overlay alone', async () => { const source = 'github:fixture/repository#fixed-ref' const specifier = `${source}&path:/.dsh-plugin` const key = createHash('sha256').update(specifier).digest('hex') @@ -717,12 +717,12 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { // deliberate external pin of the durable on-disk format. const wrapper = await generatePreparedWrapper('config-only-fixture') const output = await smoke({ - label: 'dsh personal repository Plugin', - tempDirPrefix: 'dsh-personal-repository-plugin-', + label: 'dsh overlay repository Plugin', + tempDirPrefix: 'dsh-overlay-repository-plugin-', binScript: dshBinScript, - configArgs: [], + configArgs: ['--config', '.dsh/config.yaml'], prepare: seedWorkspace({ - personal: { + harnessHome: { 'config.yaml': [ '- id: repository-plugins', " name: '@deepseek-ai/dsh-repository-plugin'", @@ -753,13 +753,13 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { expect(output).toContain('\u001B[?2004l') }, LOADER_SMOKE_TEST_TIMEOUT_MS) - it('fails loud instead of booting when the personal config.yaml is invalid', async () => { + it('fails loud instead of booting when a named --config overlay is invalid', async () => { const output = await smoke({ - label: 'dsh invalid personal config', - tempDirPrefix: 'dsh-invalid-personal-', + label: 'dsh invalid overlay', + tempDirPrefix: 'dsh-invalid-overlay-', binScript: dshBinScript, - configArgs: [], - prepare: seedWorkspace({ personal: { 'config.yaml': 'id: not-a-list\n' } }), + configArgs: ['--config', '.dsh/config.yaml'], + prepare: seedWorkspace({ harnessHome: { 'config.yaml': 'id: not-a-list\n' } }), expectedExitCode: 1, }) expect(output).toContain('must be a top-level YAML array of loader patch entries') @@ -793,18 +793,18 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { expect(output).toMatch(/To resume this session: dsh --resume=main-session-[0-9a-f-]{36} --config/) }, PTY_SMOKE_TEST_TIMEOUT_MS) - it('keeps resume working when the personal overlay replaces the whole agent-loop config', async () => { - // Loader patches replace a targeted `config` key wholesale, so a personal - // overlay repointing the model route drops every identity key the shipped + it('keeps resume working when a --config overlay replaces the whole agent-loop config', async () => { + // Loader patches replace a targeted `config` key wholesale, so an overlay + // repointing the model route drops every identity key the shipped // row declared. Launcher-owned identity makes that unreachable: agent-loop // applies the launcher's id over whatever route survives. const output = await smoke({ label: 'dsh overlay keeps resume', tempDirPrefix: 'dsh-overlay-resume-', binScript: dshBinScript, - configArgs: [], + configArgs: ['--config', '.dsh/config.yaml'], prepare: seedWorkspace({ - personal: { + harnessHome: { 'config.yaml': [ '- id: workspace-context', ' disabled: true', diff --git a/docs/user/guide/config.i18n.yaml b/docs/user/guide/config.i18n.yaml index 6d1265e9f3..525fc2f1d8 100644 --- a/docs/user/guide/config.i18n.yaml +++ b/docs/user/guide/config.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/user/guide/config.md -config.md: 6f656b573490a08ec893f4d14b487e6082015049 -config.zh.md: d4bb30023df46845ea720f3e6a45184479df0e72 +config.md: b1cf3a57b2fd16d4139f1a11a6cd85e54bb957b5 +config.zh.md: dad03d8232851678cfb6dc690f0bbd3f02380fc8 diff --git a/docs/user/guide/config.md b/docs/user/guide/config.md index 6f656b5734..b1cf3a57b2 100644 --- a/docs/user/guide/config.md +++ b/docs/user/guide/config.md @@ -50,7 +50,7 @@ Plugins load in file order. Place plugins that depend on services after the appl ## CLI overlays -The TUI composes `base.cordis.yml` and `tui.cordis.yml`, then applies one optional patch list. By default that final list is `~/.dsh/config.yaml`; `dsh --config ` replaces the personal list with the named overlay. `dsh --config-replace ` instead boots the named file as the complete tree, without shipped or personal layers. `dsh web --config ` adds its overlay after the shared base and Web surface defaults and before the Web launcher's CLI-flag patches. +The TUI composes `base.cordis.yml` and `tui.cordis.yml`, then applies the optional `dsh --config ` overlay. `dsh --config-replace ` instead boots the named file as the complete tree, without any shipped layer. Every booting surface takes both flags — `dsh -p`, `dsh web`, `dsh meta`, and `dsh upgrade` included — because naming a file is the only way to compose your own tree. A patch replaces a row's entire `config` value; it does not deep-merge keys. For example, patching `llm-deepseek` with only `config: { thinking: disabled }` also removes that row's configured `apiKey` and `baseURL`, so restate every key the row must retain. diff --git a/docs/user/guide/config.zh.md b/docs/user/guide/config.zh.md index d4bb30023d..dad03d8232 100644 --- a/docs/user/guide/config.zh.md +++ b/docs/user/guide/config.zh.md @@ -50,7 +50,7 @@ Harness 使用 `cordis.yml` 描述 Agent 加载哪些插件以及每个插件的 ## CLI 覆盖层 -TUI 先组合 `base.cordis.yml` 与 `tui.cordis.yml`,再应用一个可选补丁列表。默认的最后一层是 `~/.dsh/config.yaml`;`dsh --config ` 会以指定覆盖替代个人补丁列表。`dsh --config-replace ` 则把指定文件作为完整配置树启动,不使用已交付配置或个人层。`dsh web --config ` 会在共享基础配置与 Web 界面默认值之后、Web 启动器的命令行标志补丁之前添加覆盖。 +TUI 先组合 `base.cordis.yml` 与 `tui.cordis.yml`,再应用可选的 `dsh --config ` 覆盖。`dsh --config-replace ` 则把指定文件作为完整配置树启动,不使用任何已交付层。每个会启动的界面都接受这两个标志,包括 `dsh -p`、`dsh web`、`dsh meta` 和 `dsh upgrade`——因为点名一个文件是组合自己配置树的唯一途径。 补丁会替换目标行的整个 `config` 值,而不是深度合并各个键。例如,只用 `config: { thinking: disabled }` 修补 `llm-deepseek`,也会移除该行原有的 `apiKey` 与 `baseURL`;因此必须重新写出该行需要保留的全部键。 diff --git a/examples/mcp-memory/README.i18n.yaml b/examples/mcp-memory/README.i18n.yaml index def44e65e3..41266f9194 100644 --- a/examples/mcp-memory/README.i18n.yaml +++ b/examples/mcp-memory/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 examples/mcp-memory/README.md -README.md: b5dd7ffc4ad248d38e108d9aa28c7c26e0c76913 -README.zh.md: 1249ae40bb344fc81836cb49d71dd5656457b1b3 +README.md: 6e4c68277a99b2ac739bdfb71e6c36dfbef44e86 +README.zh.md: 66efb05e1aa1d295f1712f5f31b93f98ba68eb8e diff --git a/examples/mcp-memory/README.md b/examples/mcp-memory/README.md index b5dd7ffc4a..6e4c68277a 100644 --- a/examples/mcp-memory/README.md +++ b/examples/mcp-memory/README.md @@ -42,7 +42,7 @@ dsh --config "${DSH_HOME:-$HOME/.dsh}/memory.cordis.yml" Replace `memorix.cordis.yml` in the URL with either of the other filenames to select it. Review a downloaded overlay before running it: Cordis configuration can contain executable `!!js` expressions. -To keep the selection in personal configuration, merge the chosen file's single `insert` patch into `$DSH_HOME/config.yaml` (normally `~/.dsh/config.yaml`). Do not copy over an existing file: it may already contain unrelated personal patches. +To keep the selection across runs, merge the chosen file's single `insert` patch into your own overlay and name it on every launch (`dsh --config ~/.dsh/mcp.yml`). Do not copy over an existing overlay: it may already contain unrelated patches. ## Provider setup diff --git a/examples/mcp-memory/README.zh.md b/examples/mcp-memory/README.zh.md index 1249ae40bb..66efb05e1a 100644 --- a/examples/mcp-memory/README.zh.md +++ b/examples/mcp-memory/README.zh.md @@ -42,7 +42,7 @@ dsh --config "${DSH_HOME:-$HOME/.dsh}/memory.cordis.yml" 若要选择另外任一配置,请将 URL 中的 `memorix.cordis.yml` 替换为对应文件名。运行下载的 overlay 前,请先审阅其内容:Cordis 配置可以包含可执行的 `!!js` 表达式。 -如果要把所选配置保存在个人配置中,请将对应文件中的单个 `insert` patch 合并到 `$DSH_HOME/config.yaml`(通常是 `~/.dsh/config.yaml`)。不要覆盖已有文件,其中可能已经包含无关的个人 patch。 +如果要跨多次运行保留所选配置,请把对应文件中的单个 `insert` patch 合并到你自己的覆盖文件里,并在每次启动时点名它(`dsh --config ~/.dsh/mcp.yml`)。不要覆盖已有的覆盖文件,其中可能已经包含无关的 patch。 ## 提供方设置 diff --git a/packages/cordis/repository-plugin/README.i18n.yaml b/packages/cordis/repository-plugin/README.i18n.yaml index 8cd641781f..b64a5ea9c9 100644 --- a/packages/cordis/repository-plugin/README.i18n.yaml +++ b/packages/cordis/repository-plugin/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/cordis/repository-plugin/README.md -README.md: 0ba1ce86d99a12e0f94e7a39fd3ae44dc29889a7 -README.zh.md: 2d9544166eafbb1066b65031969925890f2b9797 +README.md: d523d0e6296fc060741b7bc8e843c1332ea1677f +README.zh.md: c3240bad3f292ecfaa51e62d93e59cbf1c69be7f diff --git a/packages/cordis/repository-plugin/README.md b/packages/cordis/repository-plugin/README.md index 0ba1ce86d9..d523d0e629 100644 --- a/packages/cordis/repository-plugin/README.md +++ b/packages/cordis/repository-plugin/README.md @@ -30,7 +30,7 @@ Place an ordinary package in the repository's `.dsh-plugin` directory: ## Standalone app configuration -The shipped `dsh` TUI, Web, and headless trees contain an empty `repository-plugins` row. A standalone user enables exact GitHub generations by replacing that row's config in `$DSH_HOME/config.yaml` (default `~/.dsh/config.yaml`): +The shipped `dsh` TUI, Web, and headless trees contain an empty `repository-plugins` row. A standalone user enables exact GitHub generations by replacing that row's config in a `--config` overlay (`dsh --config ~/.dsh/plugins.yml`): ```yaml - id: repository-plugins @@ -43,7 +43,7 @@ The shipped `dsh` TUI, Web, and headless trees contain an empty `repository-plug Each source must use `github:owner/repository#`. Omitting `&path:` selects `/.dsh-plugin`; an explicit path is absolute within the repository and must end in `.dsh-plugin`. A commit ref gives the clearest immutable identity, while tags and branches remain accepted exact config values. `cacheDir` may override the default `$DSH_HOME/cache/repository-plugins` cache root. -The TUI and Web watch `config.yaml` through Cordis HMR. A valid source-list change installs and swaps the complete repository Plugin generation; a failed fetch, prepare, import, or Plugin application keeps the last good tree and broadcasts `hmr/config-update-failed(filename, error)`. Headless runs consume the file only at startup. An identical source string permanently reuses its prepared cache entry, so selecting changed code requires a ref, path, or other source-config change. App integration rationale: [config-only repository Plugins Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md). +Every surface reads the overlay once at startup. An identical source string permanently reuses its prepared cache entry, so selecting changed code requires a ref, path, or other source-config change. App integration rationale: [config-only repository Plugins Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md). ## Preparation diff --git a/packages/cordis/repository-plugin/README.zh.md b/packages/cordis/repository-plugin/README.zh.md index 2d9544166e..c3240bad3f 100644 --- a/packages/cordis/repository-plugin/README.zh.md +++ b/packages/cordis/repository-plugin/README.zh.md @@ -30,7 +30,7 @@ ## 独立应用配置 -已交付的 `dsh` TUI、Web 和无头配置树包含一个空的 `repository-plugins` 配置项。独立用户只需在 `$DSH_HOME/config.yaml`(默认 `~/.dsh/config.yaml`)中替换该配置项的配置,即可启用精确指定的 GitHub generation: +已交付的 `dsh` TUI、Web 和无头配置树包含一个空的 `repository-plugins` 配置项。独立用户只需在一个 `--config` 覆盖文件中替换该配置项的配置(`dsh --config ~/.dsh/plugins.yml`),即可启用精确指定的 GitHub generation: ```yaml - id: repository-plugins @@ -43,7 +43,7 @@ 每个源都必须采用 `github:owner/repository#`。省略 `&path:` 时选择 `/.dsh-plugin`;显式路径是仓库内的绝对路径,并且必须以 `.dsh-plugin` 结尾。commit ref 提供最清晰的不可变身份;tag 和 branch 仍可作为显式配置值使用。`cacheDir` 可覆盖默认缓存根 `$DSH_HOME/cache/repository-plugins`。 -TUI 和 Web 通过 Cordis HMR(热模块替换)监视 `config.yaml`。有效的源列表变更会安装并替换整套仓库插件 generation;拉取、准备、导入或插件应用失败时,最后一个可用树保持运行,并广播 `hmr/config-update-failed(filename, error)`。无头运行只在启动时使用该文件。相同的源字符串会永久复用其已准备缓存条目,因此必须改变 ref、路径或其他源配置,才能选择发生变化的代码。应用集成依据见[仅凭配置接入仓库插件的 Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md)。 +每个界面都只在启动时读取该覆盖文件。相同的源字符串会永久复用其已准备缓存条目,因此必须改变 ref、路径或其他源配置,才能选择发生变化的代码。应用集成依据见[仅凭配置接入仓库插件的 Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md)。 ## 准备阶段 diff --git a/packages/ui/app-boot/README.i18n.yaml b/packages/ui/app-boot/README.i18n.yaml index be3bb757a4..ef81d9a2fd 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: 8636af748168f6d898d7b44da298636af3686001 -README.zh.md: 0d956a3f5734cd04694fb96a6c89468e99413ebc +README.md: 9b443cb0850ba989733aa2dadd587088b60a51c2 +README.zh.md: dffc9eb5205edb52d9b9a84d98d20af0b17469b0 diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index 8636af7481..9b443cb085 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -13,10 +13,8 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c | `FAIL_LOUD_RELEASE_TIMEOUT_MS` | How long `installFailLoud` waits for its `release` hook; a wedged disposer delays the fatal exit, never cancels it | | `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber, reporting every unresolved plugin name as a Cordis startup failure | | `assertEntriesActivated(ctx, binName)` | Include the `assertEntriesLoaded` check, then await every enabled entry after the Loader settles; throw with each failed plugin's original stack or each pending plugin's unresolved services | -| `loadPersonalPatches(binName, dir?)` | Parse the optional `config.yaml` in the Harness home (default [`resolveDshHome()`](../../util/paths/README.md): `$DSH_HOME`, else `~/.dsh`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws | -| `loadOverlayPatches(binName, file)` | Parse a required patch-list file with the same shape as personal config; read or parse failures throw a labelled error | -| `mountRootInclude(ctx, absoluteConfigPath, patches?)` | Mount the statically imported Include builtin and retain the exact root entry used by personal-config HMR | -| `watchPersonalPatches(ctx, options)` | Register `$DSH_HOME/config.yaml` with the existing Cordis HMR service; each add/change/removal transactionally recomposes the full patch list through the caller's `compose` closure (app-owned layers around the current personal overlay) and returns an async disposer | +| `loadOverlayPatches(binName, file)` | Parse a required patch-list file (a surface overlay or a `--config` file); read or parse failures throw a labelled error | +| `mountRootInclude(ctx, absoluteConfigPath, patches?)` | Mount the statically imported Include builtin as the boot's root entry | | `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, expose `dshHomePath(...segments)` to Loader `!!js` config expressions, install Loader, run optional host preparation before config-tree entries mount (`prepare` may use Loader and provide launcher-owned context slots such as [`MAIN_SESSION_ID_KEY`](../tui/README.md)), then mount and await the include tree, assert entries loaded and activated, and return the root context — or dispose the partial context and reject a labelled error | | `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | Compose the base config and labeled overlay layers offline — the include's own parser and patch algorithm (`entryListSchema`/`applyEntryPatches`), so the result equals what `boot()` mounts — and render YAML with `!!js` expressions verbatim; each run of same-provenance rows is preceded by a `# ==` comment naming the contributing file and the layers that patched it, keeping the output one loadable document; a patch matching no row goes to `warn` with its layer label (default: one stderr line), read/parse/shape failures throw | | `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to the DSH implementation checkout while warning it not to infer the current working directory from that path and to use `pwd` instead; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot | @@ -30,17 +28,15 @@ Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve This package carries no loader hooks and no dev-mode surface. The [`dsh` app](../../../apps/cli/README.md) owns its Node source-launch hook and consumes these helpers for the boot sequence; built consumers continue to use plain Node package resolution. -## Personal config +## The Harness home -A developer's machine-local preferences live outside every repository in the Harness home (default `~/.dsh`, overridable via `$DSH_HOME`; the single root [`resolveDshHome`](../../util/paths/README.md) resolves), consumed by the `dsh` CLI's TUI, Web, and headless surfaces ([`apps/cli`](../../../apps/cli/README.md)); the demo bins boot their committed trees verbatim. Two optional files: +A developer's machine-local state lives outside every repository in the Harness home (default `~/.dsh`, overridable via `$DSH_HOME`; the single root [`resolveDshHome`](../../util/paths/README.md) resolves). What this package reads from it is one file: - **`.env`** — the user's ordinary environment layer, loaded by the `dsh` bin through `loadLayeredEnv` beneath the invoking directory's `.env` and the inherited environment. It is plain environment with plain environment reach, not a secret boundary: what the Harness owns and isolates lives in `.credentials.yaml`, which no surface hoists. A key placed in this file therefore still resolves — as a read-only `env` layer that shadows the stored one and blocks rotation from the TUI and the web page. -- **`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. -The TUI and Web keep `config.yaml` live through `watchPersonalPatches`; one-shot headless runs read only the startup value. The watcher targets the exact personal path even when the file or immediate parent does not exist, serializes bursts, and recomposes the personal patches inside the caller's layer order (surface overlay below, app-generated patches above). A rejected read, parse, or Loader candidate leaves the last good tree running and the HMR service broadcasts `hmr/config-update-failed(filename, Error)` after logging it; observer failures are contained. Disposing the context closes the watcher and drains an active refresh. - -Subprocess test launchers point `DSH_HOME` at an isolated per-test directory so a developer's personal overlay can never leak into fixtures. +There is no automatically discovered composition file. Loader overlays reach a surface only by being named: `dsh --config ` layers a patch list over the shipped tree and `dsh --config-replace ` boots one instead of it, on every booting surface. Keeping an overlay in `~/.dsh` is fine — it is a location, not a layer, and nothing loads it unless the launch names it ([rationale](../../../.agents/notes/implemented/simplification/2026-08-04-remove-personal-composition-layer.md)). +Subprocess test launchers point `DSH_HOME` at an isolated per-test directory so a developer's own files can never leak into fixtures. ## Model Experience Indirectly, through the plugin tree it loads, which determines the prompts, schemas, messages, and model adapter in the resulting application; the one export that contributes model-visible text, `addHarnessSourceSection`, does so only when a consumer calls it after boot. @@ -54,4 +50,4 @@ No direct invalidation from `boot()`; a consumer that calls `addHarnessSourceSec - **Bare package specifiers depend on Loader internals** — production bins need Loader's optional native helper; an in-process caller without it must use resolvable relative/file specifiers or provide its own module-resolution hook. - **Snapshot replay swapping is basename-specific** — only a config ending in `cordis.yml` or `cordis.yaml` maps to the sibling `cordis.snapshot.yml`; custom config names require caller-managed selection. - **Environment loading is directory-scoped and optional** — each layer is one named directory's `.env`, and a failure warns; neither helper searches parents or validates required variables. `loadLayeredEnv` fixes its two layers at the invoking directory and the Harness home, so a caller wanting different layers composes `loadEnv` itself. -- **Personal config is patch-shaped** — an id-targeted patch replaces the entry's whole `config` rather than deep-merging, so a personal override restates the base fields it keeps. +- **Overlays are patch-shaped** — an id-targeted patch replaces the entry's whole `config` rather than deep-merging, so an override restates the base fields it keeps. diff --git a/packages/ui/app-boot/README.zh.md b/packages/ui/app-boot/README.zh.md index 0d956a3f57..dffc9eb520 100644 --- a/packages/ui/app-boot/README.zh.md +++ b/packages/ui/app-boot/README.zh.md @@ -13,10 +13,8 @@ | `FAIL_LOUD_RELEASE_TIMEOUT_MS` | `installFailLoud` 等待其 `release` 回调的时长;卡住的 disposer 只会延迟致命退出,而不会取消它 | | `assertEntriesLoaded(ctx, binName)` | 树结算后,如果其中存在已启用但没有 fiber 的条目,则抛出异常,并以 Cordis 启动故障的形式报告每个未解析插件的名称 | | `assertEntriesActivated(ctx, binName)` | 先执行 `assertEntriesLoaded` 检查,再在 Loader 结算后等待每个已启用配置项;抛出的错误包含每个失败插件的原始错误堆栈,或每个等待中插件尚未解析的服务 | -| `loadPersonalPatches(binName, dir?)` | 解析 Harness home 中可选的 `config.yaml`(默认使用 [`resolveDshHome()`](../../util/paths/README.md):先取 `$DSH_HOME`,否则取 `~/.dsh`):其顶层是一个 YAML 数组,内容为 include 的 `PatchOptions`(按 id 定位的配置覆盖、`insert` 列表,允许 `!!js`);文件不存在时返回 `undefined`,文件不可读、不可解析或内容不是数组时抛出异常 | -| `loadOverlayPatches(binName, file)` | 解析一份必需的 patch 列表文件,其形状与个人配置相同;读取或解析失败时抛出带标签的错误 | -| `mountRootInclude(ctx, absoluteConfigPath, patches?)` | 挂载静态导入的 Include builtin,并保留个人配置 HMR(热模块替换)使用的确切根配置项 | -| `watchPersonalPatches(ctx, options)` | 向现有 Cordis HMR 服务注册 `$DSH_HOME/config.yaml`;每次新增、变更或移除都会通过调用方的 `compose` 闭包(应用自有层围绕当前个人 overlay)以事务方式重新组合完整 patch 列表,并返回异步 disposer | +| `loadOverlayPatches(binName, file)` | 解析一份必需的 patch 列表文件(surface overlay 或 `--config` 文件);读取或解析失败时抛出带标签的错误 | +| `mountRootInclude(ctx, absoluteConfigPath, patches?)` | 挂载静态导入的 Include builtin,作为本次启动的根配置项 | | `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文,向 Loader `!!js` 配置表达式暴露 `dshHomePath(...segments)` 并安装 Loader,在配置树条目挂载前执行可选的宿主准备操作(`prepare` 可以使用 Loader,也可以提供由启动器拥有的上下文插槽,例如 [`MAIN_SESSION_ID_KEY`](../tui/README.md)),再挂载并等待 include 树结算,断言所有条目均已加载并激活,最后返回根上下文——失败时 dispose(资源释放)部分构造的上下文,并以带标签的错误 reject | | `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | 离线合成基础配置与带标签的覆盖层——使用 include 自己的解析器和补丁算法(`entryListSchema`/`applyEntryPatches`),因此结果与 `boot()` 挂载的内容一致——并渲染为 YAML,`!!js` 表达式原样保留;每段来源相同的连续行之前都有一条 `# ==` 注释,标明贡献该段的文件以及修补过它的层,输出仍是一份可加载的文档;未匹配到行的补丁连同其层标签交给 `warn`(默认:一行 stderr),读取/解析/形状失败则抛出 | | `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent(智能体)DSH 实现代码 checkout 的磁盘路径,同时提醒它不得据此推断当前工作目录,而应使用 `pwd`;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber,因此开发环境 HMR(热模块替换)重新加载系统提示词后,它会消失直至下次启动 | @@ -30,17 +28,15 @@ Loader 并发挂载各个条目,因此当其他环节失败时,某个界面 此包不包含 loader 钩子,也不提供开发模式接口。[`dsh` 应用](../../../apps/cli/README.md)持有自己的 Node 源码启动钩子,并在启动序列中使用这些 helper;构建后的消费方仍使用普通 Node 包解析。 -## 个人配置 +## Harness home -开发者的机器本地偏好位于所有仓库之外的 Harness home 中(默认 `~/.dsh`,可由 `$DSH_HOME` 覆盖;统一由根级 [`resolveDshHome`](../../util/paths/README.md) 解析),并由 `dsh` CLI(命令行界面)的 TUI、Web 和无头界面([`apps/cli`](../../../apps/cli/README.md))使用;demo bin 会原样启动仓库中提交的树。这里有两个可选文件: +开发者的机器本地状态位于所有仓库之外的 Harness home 中(默认 `~/.dsh`,可由 `$DSH_HOME` 覆盖;统一由根级 [`resolveDshHome`](../../util/paths/README.md) 解析)。本包从中读取的只有一个文件: - **`.env`**:用户的普通环境层,由 `dsh` bin 经 `loadLayeredEnv` 加载,位于调用目录的 `.env` 与继承环境之下。它是具有普通环境作用域的普通环境值,而不是密钥边界:由 Harness 拥有并隔离的东西放在 `.credentials.yaml` 里,后者不会被任何表层提升。因此放进本文件的密钥仍然可以解析——但会作为只读的 `env` 层遮蔽已存储的那一份,并阻断从 TUI 与 Web 页面轮换密钥。 -- **`config.yaml`**:在发布的默认配置上应用 Loader overlay patch,语义与交付的 surface overlay 相同:按 id 定位的 patch 会替换对应条目的整个 `config`(未改字段也要重述),`insert` 会添加条目,`!!js` 表达式则在挂载时插值。如果 patch 指定的条目 id 不在已启动树中,则静默不执行任何操作。空文件或仅含注释的文件会抛出异常(其解析结果为空,而不是列表);如需禁用 overlay,请使用 `[]` 或删除该文件。 -TUI 和 Web 会持续应用 `config.yaml` 的变更,具体由 `watchPersonalPatches` 负责;一次性无头运行只读取启动时的值。即使该文件或其直接父目录不存在,watcher 仍会监视确切的个人配置路径;它会串行处理突发变更,并按调用方的层次顺序重新组合个人 patch(surface overlay 在下、应用生成的 patch 在上)。读取失败、解析失败或 Loader 候选被拒时,最后一个可用树会继续运行;HMR 服务记录错误后广播 `hmr/config-update-failed(filename, Error)`,并隔离 observer 失败。上下文 dispose 时会关闭 watcher,并等待进行中的刷新结束。 - -子进程测试 launcher 会把 `DSH_HOME` 指向逐测试隔离的目录,确保开发者的个人 overlay 不会泄漏到 fixture(测试前置数据)中。 +不存在会被自动发现的组合文件。Loader overlay 只有被点名才会抵达某个界面:`dsh --config ` 在已交付配置树上叠加一个 patch 列表,`dsh --config-replace ` 则用它取代整棵树,两者在每个会启动的界面上都可用。把 overlay 放在 `~/.dsh` 里没有问题——那只是一个位置,不是一层,启动时不点名就不会加载它([依据](../../../.agents/notes/implemented/simplification/2026-08-04-remove-personal-composition-layer.md))。 +子进程测试启动器会把 `DSH_HOME` 指向每个测试独立的目录,因此开发者自己的文件绝不会泄漏进 fixture。 ## 模型体验 模型通过此包加载的插件树间接受到影响;该树决定最终应用中的提示词、schema、消息和模型适配器。唯一贡献模型可见文本的导出 `addHarnessSourceSection`,也只有在消费方启动后调用它时才会产生影响。 @@ -54,4 +50,4 @@ TUI 和 Web 会持续应用 `config.yaml` 的变更,具体由 `watchPersonalPa - **裸包 specifier 依赖 Loader 内部机制**:生产 bin 需要 Loader 的可选原生 helper;没有该 helper 的进程内调用方必须使用可解析的相对/file specifier,或提供自己的模块解析钩子。 - **快照回放替换仅识别特定 basename**:只有以 `cordis.yml` 或 `cordis.yaml` 结尾的配置会映射到同级 `cordis.snapshot.yml`;自定义配置名称需要调用方自行选择。 - **环境加载按目录划分且为可选操作**:每一层都是一个指定目录下的 `.env`,失败时发出警告;两个 helper 都不会搜索父目录,也不验证必需变量。`loadLayeredEnv` 的两层固定为调用目录与 Harness home,需要其他层次的调用方请自行组合 `loadEnv`。 -- **个人配置采用 patch 形式**:按 id 定位的 patch 会替换条目的整个 `config`,而不是深度合并,因此个人覆盖必须重述需要保留的基础字段。 +- **overlay 采用 patch 形式**:按 id 定位的 patch 会替换条目的整个 `config`,而不是深度合并,因此覆盖必须重述需要保留的基础字段。 diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index 94a7aa2c7d..78dff3eca8 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -1,14 +1,14 @@ /** * Shared boot glue for the app bins (`dsh`, `dsh-cli-demo`, `dsh-acp-demo`): load the gitignored * `.env` files, install the fail-loud Loader guards, resolve the config path (snapshot-aware), load the - * optional personal overlay patches from the Harness home (`~/.dsh`), expose its path resolver to + * explicit overlay patch lists a surface composes, expose the Harness-home path resolver to * config expressions, and drive the Cordis Loader against a leaf `cordis.yml` until the tree settles. * @module @deepseek-ai/dsh-app-boot */ import { pathToFileURL } from 'node:url' import { readFileSync } from 'node:fs' -import { basename, dirname, join, resolve } from 'node:path' +import { basename, dirname, resolve } from 'node:path' import * as yaml from 'js-yaml' import { Context, type FiberState } from 'cordis' import Loader, { type Entry, type EntryOptions } from '@cordisjs/plugin-loader' @@ -95,49 +95,15 @@ export function loadLayeredEnv( loadEnv(binName, home, warn) } -/** File inside the Harness home holding the personal loader overlay patches. */ -export const PERSONAL_CONFIG_FILENAME = 'config.yaml' - -const bootstrapIncludes = new WeakMap() - -// The include's YAML dialect (`!!js` scalars become expression nodes the -// Loader interpolates against each entry's context at mount time), imported -// from the include itself so patch parsing and config dumping can never drift -// from what the include mounts. Personal patches share it so they may -// reference `process.env`. -const personalPatchesSchema = entryListSchema - /** - * Load the optional personal overlay patches (`config.yaml` under the Harness - * home). The file is a top-level YAML array of loader patch entries - * (`@cordisjs/plugin-include`'s `PatchOptions`): id-targeted config overrides - * and `insert` lists, with `!!js` expressions allowed. A missing file means - * "no personal overlay"; an unreadable, unparsable, or non-array file throws — - * a present personal config that cannot apply is a misconfiguration and must - * fail loud at boot, never be silently skipped. - * @param binName - the diagnostic prefix on the thrown error. - * @param dir - the Harness home; defaults to {@link resolveDshHome} (`$DSH_HOME` or `~/.dsh`). - * @returns the parsed patches, or `undefined` when the file does not exist. - */ -export function loadPersonalPatches( - binName: string, dir: string = resolveDshHome(), -): PatchOptions[] | undefined { - const file = join(dir, PERSONAL_CONFIG_FILENAME) - let content: string - try { - content = readFileSync(file, 'utf8') - } catch (error) { - if ((error as NodeJS.ErrnoException | null)?.code === 'ENOENT') return undefined - throw new Error(`${binName}: failed to read personal patches ${file}: ${String(error)}`) - } - return parsePatchList(binName, file, content, 'personal patches') -} - -/** - * Load a required overlay patch list: a surface overlay (`tui.cordis.yml`) or a - * `--config ` overlay applied over the shared base. Same file format as - * {@link loadPersonalPatches}, but a missing file throws, because the caller - * named this file — its absence is a misconfiguration, not "no overlay". + * Load an overlay patch list: a surface overlay (`tui.cordis.yml`) or a + * `--config ` overlay applied over the shared base. The file is a + * top-level YAML array of loader patch entries (`@cordisjs/plugin-include`'s + * `PatchOptions`): id-targeted config overrides and `insert` lists, with + * `!!js` expressions allowed — the dialect is imported from the include + * itself, so patch parsing and config dumping can never drift from what the + * include mounts. A missing file throws, because the caller named this file: + * its absence is a misconfiguration, not "no overlay". * @param binName - the diagnostic prefix on the thrown error. * @param file - absolute path of the overlay file. * @returns the parsed patch list. @@ -149,37 +115,32 @@ export function loadOverlayPatches(binName: string, file: string): PatchOptions[ } catch (error) { throw new Error(`${binName}: failed to read overlay ${file}: ${String(error)}`) } - return parsePatchList(binName, file, content, 'overlay') + return parsePatchList(binName, file, content) } /** - * Parse one loader patch list: a top-level YAML array of - * `@cordisjs/plugin-include` `PatchOptions` (id-targeted config overrides and - * `insert` lists, `!!js` expressions allowed). Every shape failure throws, - * because a patch file that cannot be applied at all is a misconfiguration; a - * single patch whose target row is absent stays a per-entry Loader warning, so - * one overlay shared across surfaces does not have to match every tree. + * Parse one loader patch list. Every shape failure throws, because a patch + * file that cannot be applied at all is a misconfiguration; a single patch + * whose target row is absent stays a per-entry Loader warning, so one overlay + * shared across surfaces does not have to match every tree. * @param binName - the diagnostic prefix on the thrown error. * @param file - the source path, quoted in errors. * @param content - the file's text. - * @param label - what to call this list in errors (`personal patches`, `overlay`). * @returns the parsed patch list. */ -function parsePatchList( - binName: string, file: string, content: string, label: string, -): PatchOptions[] { +function parsePatchList(binName: string, file: string, content: string): PatchOptions[] { let parsed: unknown try { - parsed = yaml.load(content, { schema: personalPatchesSchema }) + parsed = yaml.load(content, { schema: entryListSchema }) } catch (error) { - throw new Error(`${binName}: failed to parse ${label} ${file}: ${String(error)}`) + throw new Error(`${binName}: failed to parse overlay ${file}: ${String(error)}`) } if (!Array.isArray(parsed)) { - throw new Error(`${binName}: ${label} ${file} must be a top-level YAML array of loader patch entries`) + throw new Error(`${binName}: overlay ${file} must be a top-level YAML array of loader patch entries`) } parsed.forEach((entry, index) => { if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) { - throw new Error(`${binName}: ${label} entry ${index + 1} in ${file} must be a mapping (a loader patch entry)`) + throw new Error(`${binName}: overlay entry ${index + 1} in ${file} must be a mapping (a loader patch entry)`) } }) return parsed as PatchOptions[] @@ -189,7 +150,7 @@ function parsePatchList( export interface ConfigDumpLayer { /** Source name shown in provenance comments (a file basename or path). */ label: string - /** The layer's patches, from {@link loadOverlayPatches} / {@link loadPersonalPatches}. */ + /** The layer's patches, from {@link loadOverlayPatches}. */ patches: PatchOptions[] } @@ -320,70 +281,11 @@ function groupedDump( return lines.join('\n') + '\n' } -/** Options for live personal-config reconciliation. */ -export interface PersonalPatchWatchOptions { - /** Diagnostic prefix used by {@link loadPersonalPatches}. */ - binName: string - /** Harness home containing `config.yaml`; defaults to {@link resolveDshHome}. */ - dir?: string - /** - * Compose the full patch list for a fresh personal-overlay generation — - * the same composition the app booted with, so a reload can interleave the - * new personal patches between app-owned layers (surface overlay below, - * profile/flag patches above). Identity when omitted: the personal overlay - * is the whole patch list. - */ - compose?: (personalPatches: PatchOptions[]) => PatchOptions[] -} - /** - * Watch the personal overlay through Cordis HMR and transactionally reapply it to the boot include. - * @param ctx - settled app context containing the root Include and an active HMR service. - * @param options - diagnostic, Harness-home, and patch-composition inputs. - * @returns an asynchronous disposer after the exact-path watcher is ready. - * @throws when HMR or the root Include is absent, watcher setup fails, or initial path resolution fails. - */ -export async function watchPersonalPatches( - ctx: Context, - options: PersonalPatchWatchOptions, -): Promise<() => Promise> { - const { binName, dir = resolveDshHome(), compose = (patches: PatchOptions[]) => patches } = options - const hmr = ctx.get('hmr') - if (hmr === undefined) throw new Error(`${binName}: personal config watching requires the Cordis HMR service`) - const entry = bootstrapIncludes.get(ctx) - if (entry === undefined) throw new Error(`${binName}: personal config watching requires the root Include entry`) - const filename = join(dir, PERSONAL_CONFIG_FILENAME) - const register = hmr.registerConfig(filename, async () => { - // Re-read the include's non-patch options per refresh: a writer that - // updates the root Include's other options between refreshes (none exists - // today) must not have them silently reverted by a personal reload. - const { patches: _previousPatches, ...includeConfig } = entry.options.config as Include.Config - const personalPatches = loadPersonalPatches(binName, dir) ?? [] - const patches = compose(personalPatches) - await entry.update({ - config: { - ...includeConfig, - patches, - }, - }) - }) - try { - return await register - } catch (error) { - // A surface can dispose the whole tree while the watcher is still opening - // (a TUI `/exit` typed during startup): the HMR effect registration then - // fails with INACTIVE_EFFECT. That is the app exiting exactly as asked, - // not a watch failure — return a no-op disposer instead of crashing. - if ((error as { code?: string } | null)?.code === 'INACTIVE_EFFECT') return async () => {} - throw error - } -} - -/** - * Mount and remember the exact root Include entry used by app boot and personal-config HMR. + * Mount the root Include entry app boot drives. * @param ctx - context carrying an initialized Loader service. * @param absoluteConfigPath - absolute YAML or JSON configuration path. - * @param patches - initial app and personal patches, applied in order. + * @param patches - the surface's overlay patches, applied in order. * @returns the created root Include entry, or `undefined` when a surface * disposed the whole tree (taking the Loader service with it) while the * transactional create was still settling entry lifecycle. @@ -408,9 +310,7 @@ export async function mountRootInclude( const includeId = await ctx.loader.create(rootInclude) const loader = ctx.get('loader') if (loader === undefined) return undefined - const entry = loader.resolve(includeId) - bootstrapIncludes.set(ctx, entry) - return entry + return loader.resolve(includeId) } /** @@ -629,7 +529,7 @@ export async function assertEntriesActivated(ctx: Context, binName: string): Pro * @param absoluteConfigPath - the config to include; must already be absolute * (see {@link resolveConfigPath}). * @param patches - optional overlay patches applied over the included tree - * (see {@link loadPersonalPatches}); an empty list mounts none. + * (see {@link loadOverlayPatches}); an empty list mounts none. * @param prepare - optional host setup run after Loader installation and before any config-tree entry mounts. * @returns the root context once every entry has started, or as soon as a * surface disposed the tree while startup was still in flight. diff --git a/packages/ui/app-boot/tests/config-dump.spec.ts b/packages/ui/app-boot/tests/config-dump.spec.ts index 99af81f2c2..4f5d8e83e5 100644 --- a/packages/ui/app-boot/tests/config-dump.spec.ts +++ b/packages/ui/app-boot/tests/config-dump.spec.ts @@ -49,17 +49,17 @@ describe('renderConfigDump', () => { ' name: ./noop.mjs', '', ].join('\n')) - const personal = join(dir, 'personal.yml') - writeFileSync(personal, [ + const user = join(dir, 'user.yml') + writeFileSync(user, [ '- id: surface-extra', ' config:', - ' value: personal', + ' value: user', '', ].join('\n')) const dump = renderConfigDump(NAME, base, [ { label: 'surface.yml', patches: loadOverlayPatches(NAME, surface) }, - { label: 'personal.yml', patches: loadOverlayPatches(NAME, personal) }, + { label: 'user.yml', patches: loadOverlayPatches(NAME, user) }, ], () => {}) // Comments do not break loadability: the dump parses as one document // equal to what boot() would mount. @@ -74,7 +74,7 @@ describe('renderConfigDump', () => { config: { value: 'surface', key: { __jsExpr: 'process.env.DSH_DUMP_SPEC' } }, }, { id: 'untouched', name: './noop.mjs' }, - { id: 'surface-extra', name: './noop.mjs', config: { value: 'personal' } }, + { id: 'surface-extra', name: './noop.mjs', config: { value: 'user' } }, ]) // Unevaluated: the expression text round-trips as a !!js scalar. expect(dump).toContain('!!js process.env.DSH_DUMP_SPEC') @@ -82,7 +82,7 @@ describe('renderConfigDump', () => { // row; an inserted row carries the inserting layer as its origin. expect(dump).toContain('# == base.yml, patched by surface.yml') expect(dump).toContain('# == base.yml\n- id: untouched') - expect(dump).toContain('# == surface.yml, patched by personal.yml\n- id: surface-extra') + expect(dump).toContain('# == surface.yml, patched by user.yml\n- id: surface-extra') expect(dump.indexOf('# == base.yml, patched by surface.yml')).toBeLessThan(dump.indexOf('# == base.yml\n- id: untouched')) }) diff --git a/packages/ui/app-boot/tests/config-reload.spec.ts b/packages/ui/app-boot/tests/config-reload.spec.ts index d9f4ffa830..81ba2fc845 100644 --- a/packages/ui/app-boot/tests/config-reload.spec.ts +++ b/packages/ui/app-boot/tests/config-reload.spec.ts @@ -341,12 +341,12 @@ describe('include refresh with overlay patches', () => { describe('include patches layered over one base', () => { it('lets a later patch configure or disable a row an earlier patch inserted', async () => { - // The surface/`--config`/personal composition: `dsh` includes one shared - // base and applies each source as its own patch list at the SAME include - // level, because patches never cross an include boundary. A later layer - // must therefore be able to reach a row an earlier layer inserted — - // otherwise every surface-only row (the whole TUI front door) would be - // invisible to the user's `~/.dsh/config.yaml`. + // The surface/`--config` composition: `dsh` includes one shared base and + // applies each source as its own patch list at the SAME include level, + // because patches never cross an include boundary. A later layer must + // therefore be able to reach a row an earlier layer inserted — otherwise + // every surface-only row (the whole TUI front door) would be invisible to + // the user's `--config` overlay. const dir = mkdtempSync(join(tmpdir(), 'dsh-config-layered-')) writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN) writeFileSync(join(dir, 'base.yml'), '- id: shared\n name: ./noop.mjs\n config:\n value: base\n') @@ -370,7 +370,7 @@ describe('include patches layered over one base', () => { // Layer 2 (the user): reconfigure one inserted row and disable the other. ' - id: surface-kept', ' config:', - ' value: personal', + ' value: user', ' - id: surface-dropped', ' disabled: true', '', @@ -378,7 +378,7 @@ describe('include patches layered over one base', () => { const ctx = await boot(NAME, join(dir, 'cordis.yml')) try { expect(entryConfig(ctx, 'shared')).toEqual({ value: 'surface' }) - expect(entryConfig(ctx, 'surface-kept')).toEqual({ value: 'personal' }) + expect(entryConfig(ctx, 'surface-kept')).toEqual({ value: 'user' }) const dropped = [...ctx.loader.entries()].find(entry => entry.options.id === 'surface-dropped') expect(dropped?.options.disabled).toBe(true) expect(dropped?.fiber).toBeUndefined() diff --git a/packages/ui/app-boot/tests/personal-config.spec.ts b/packages/ui/app-boot/tests/personal-config.spec.ts deleted file mode 100644 index 53df1d84b7..0000000000 --- a/packages/ui/app-boot/tests/personal-config.spec.ts +++ /dev/null @@ -1,270 +0,0 @@ -/** - * Personal-config behavior of `dsh-app-boot`: the Harness home (`~/.dsh`) - * `config.yaml` overlay loader and `boot()` applying the personal overlay over - * a real Loader tree. - */ - -import { mkdirSync, mkdtempSync, unlinkSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { pathToFileURL } from 'node:url' -import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import Hmr from '@cordisjs/plugin-hmr' -import Loader from '@cordisjs/plugin-loader' -import Timer from '@cordisjs/plugin-timer' -import { - boot, - loadPersonalPatches, - PERSONAL_CONFIG_FILENAME, - watchPersonalPatches, -} from '../src/index.ts' - -const NAME = 'dsh-test-bin' - -const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-personal-config-')) - -async function eventually(test: () => boolean, message: string): Promise { - const deadline = Date.now() + 10_000 - while (!test()) { - if (Date.now() >= deadline) throw new Error(message) - await new Promise(resolve => setTimeout(resolve, 10)) - } -} - -const settleChokidarChangeThrottle = (): Promise => new Promise(resolve => setTimeout(resolve, 75)) - -describe('loadPersonalPatches', () => { - afterEach(() => { - delete process.env.DSH_HOME - }) - - it('returns undefined when no personal patches file exists', () => { - expect(loadPersonalPatches(NAME, tmp())).toBeUndefined() - }) - - it('parses a patch list and preserves !!js expressions as loader expression nodes', () => { - const dir = tmp() - writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), [ - '- id: tui-agent', - " name: '@deepseek-ai/dsh-tui-demo'", - ' config:', - ' model: !!js process.env.DSH_SPEC_MODEL', - '- insert:', - ' - id: llm', - " name: '@deepseek-ai/dsh-llm-pi-ai'", - '', - ].join('\n')) - const patches = loadPersonalPatches(NAME, dir) - expect(patches).toHaveLength(2) - expect(patches?.[0]).toMatchObject({ - id: 'tui-agent', - config: { model: { __jsExpr: 'process.env.DSH_SPEC_MODEL' } }, - }) - expect(patches?.[1]?.insert).toHaveLength(1) - }) - - it('defaults its directory to the Harness home ($DSH_HOME)', () => { - const dir = tmp() - writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), '- id: x\n config:\n a: 1\n') - process.env.DSH_HOME = dir - expect(loadPersonalPatches(NAME)).toHaveLength(1) - }) - - it('fails loud on an unreadable file (a present personal config is never skipped)', () => { - const dir = tmp() - mkdirSync(join(dir, PERSONAL_CONFIG_FILENAME)) // a directory: present, unreadable as a file - expect(() => loadPersonalPatches(NAME, dir)) - .toThrow(new RegExp(`^${NAME}: failed to read personal patches `)) - }) - - it('fails loud on unparsable YAML and on a !!js tag with no expression body', () => { - const dir = tmp() - writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), 'invalid: [unclosed\n') - expect(() => loadPersonalPatches(NAME, dir)) - .toThrow(new RegExp(`^${NAME}: failed to parse personal patches `)) - writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), '- id: x\n config:\n a: !!js\n') - expect(() => loadPersonalPatches(NAME, dir)) - .toThrow(new RegExp(`^${NAME}: failed to parse personal patches `)) - }) - - it('fails loud when the file is not a top-level array or an entry is not an object', () => { - const dir = tmp() - writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), 'id: not-a-list\n') - expect(() => loadPersonalPatches(NAME, dir)) - .toThrow('must be a top-level YAML array of loader patch entries') - writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), '- just-a-string\n') - expect(() => loadPersonalPatches(NAME, dir)) - .toThrow(`${NAME}: personal patches entry 1 in`) - }) -}) - -describe('boot with personal patches', () => { - function writeTree(dir: string): string { - writeFileSync(join(dir, 'noop.mjs'), [ - 'export const name = "noop"', - 'export function apply(_ctx, config = {}) {', - ' if (config.fail) throw new Error("candidate config failed")', - '}', - '', - ].join('\n')) - writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: base\n') - return join(dir, 'cordis.yml') - } - - function entryConfig(ctx: Context, id: string): unknown { - return [...ctx.loader.entries()].find(entry => entry.options.id === id)?.options.config - } - - it('applies id-targeted overrides, inserts, and interpolates !!js from the environment', async () => { - const dir = tmp() - const personal = tmp() - writeFileSync(join(personal, PERSONAL_CONFIG_FILENAME), [ - '- id: noop', - ' name: ./noop.mjs', - ' config:', - ' value: !!js process.env.DSH_APP_BOOT_PERSONAL_SPEC', - '- insert:', - ' - id: personal-extra', - ' name: ./noop.mjs', - '', - ].join('\n')) - process.env['DSH_APP_BOOT_PERSONAL_SPEC'] = 'personal-value' - const ctx = await boot(NAME, writeTree(dir), loadPersonalPatches(NAME, personal)) - try { - const noop = [...ctx.loader.entries()].find(entry => entry.options.id === 'noop') - // The mounted plugin received the interpolated environment value. - expect(noop?.fiber?.config).toEqual({ value: 'personal-value' }) - expect([...ctx.loader.entries()].some(entry => entry.options.id === 'personal-extra')).toBe(true) - } finally { - await ctx.fiber.dispose() - delete process.env['DSH_APP_BOOT_PERSONAL_SPEC'] - } - }) - - it('mounts no patch layer for an absent or empty personal overlay', async () => { - const dir = tmp() - const ctx = await boot(NAME, writeTree(dir), loadPersonalPatches(NAME, tmp())) - try { - expect(entryConfig(ctx, 'noop')).toEqual({ value: 'base' }) - } finally { - await ctx.fiber.dispose() - } - const empty = tmp() - writeFileSync(join(empty, PERSONAL_CONFIG_FILENAME), '[]\n') - const ctxEmpty = await boot(NAME, writeTree(tmp()), loadPersonalPatches(NAME, empty)) - try { - expect(entryConfig(ctxEmpty, 'noop')).toEqual({ value: 'base' }) - } finally { - await ctxEmpty.fiber.dispose() - } - }) - - it('watches add, failure, recovery, and removal through transactional HMR', { timeout: 20_000 }, async () => { - const dir = tmp() - const personal = tmp() - const filename = join(personal, PERSONAL_CONFIG_FILENAME) - const basePatches = [{ id: 'noop', config: { value: 'generated' } }] - const ctx = await boot(NAME, writeTree(dir), basePatches) - await ctx.plugin(Timer) - await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 }) - const failures: Array<{ filename: string; error: Error }> = [] - ctx.on('hmr/config-update-failed', (failedFilename, error) => { - failures.push({ filename: failedFilename, error }) - }) - const dispose = await watchPersonalPatches(ctx, { - binName: NAME, - dir: personal, - compose: personalPatches => [...basePatches, ...personalPatches], - }) - try { - writeFileSync(filename, '- id: noop\n config:\n value: live\n') - await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'live', 'personal config addition was not applied') - - writeFileSync(filename, '- id: noop\n config:\n fail: true\n') - await eventually(() => failures.length === 1, 'failed candidate was not broadcast') - expect(failures[0]).toMatchObject({ filename }) - expect(failures[0]?.error).toBeInstanceOf(Error) - expect((entryConfig(ctx, 'noop') as { value?: string }).value).toBe('live') - await settleChokidarChangeThrottle() - - writeFileSync(filename, 'invalid: [unclosed\n') - await eventually(() => failures.length === 2, 'parse failure was not broadcast') - expect(failures[1]?.error).toBeInstanceOf(Error) - expect((entryConfig(ctx, 'noop') as { value?: string }).value).toBe('live') - await settleChokidarChangeThrottle() - - writeFileSync(filename, '- id: noop\n config:\n value: recovered\n') - await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'recovered', 'valid recovery was not applied') - await settleChokidarChangeThrottle() - - unlinkSync(filename) - await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'generated', 'personal config removal did not restore the app-owned patch') - expect(failures).toHaveLength(2) - await settleChokidarChangeThrottle() - - // Default compose: the personal overlay IS the whole patch list, so a - // fresh generation replaces the app-owned layer instead of stacking on it. - await dispose() - const disposeDefault = await watchPersonalPatches(ctx, { binName: NAME, dir: personal }) - try { - writeFileSync(filename, '- id: noop\n config:\n value: identity\n') - await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'identity', 'default-compose personal patch was not applied') - } finally { - await disposeDefault() - } - } finally { - await dispose() - await ctx.fiber.dispose() - } - }) - - it('fails loud when the exact watcher lacks HMR or a root Include', async () => { - const dir = tmp() - const withoutHmr = await boot(NAME, writeTree(dir)) - await expect(watchPersonalPatches(withoutHmr, { binName: NAME, dir: tmp() })).rejects.toThrow('requires the Cordis HMR service') - await withoutHmr.fiber.dispose() - - const withoutInclude = new Context() - withoutInclude.baseUrl = pathToFileURL(`${tmp()}/`).href - await withoutInclude.plugin(Loader) - await withoutInclude.plugin(Timer) - await withoutInclude.plugin(Hmr, { root: [], ignored: [], debounce: 0 }) - await expect(watchPersonalPatches(withoutInclude, { binName: NAME, dir: tmp() })).rejects.toThrow('requires the root Include entry') - await withoutInclude.fiber.dispose() - }) - - it('returns a no-op disposer when the tree is disposed while the watcher opens', async () => { - // A TUI `/exit` typed during startup disposes the whole tree while - // registerConfig's effect registration is still in flight (the HMR effect - // then fails with INACTIVE_EFFECT); the app is exiting exactly as asked, - // so the watcher must not crash the process. The stub makes the race - // deterministic — the live-teardown ordering itself is not stageable. - const dir = tmp() - const ctx = await boot(NAME, writeTree(dir)) - try { - const teardown = Object.assign(new Error('cannot create effect on inactive context'), { code: 'INACTIVE_EFFECT' }) - ctx.provide('hmr', { registerConfig: () => Promise.reject(teardown) }) - const dispose = await watchPersonalPatches(ctx, { binName: NAME, dir: tmp() }) - await expect(dispose()).resolves.toBeUndefined() - } finally { - await ctx.fiber.dispose() - } - }) - - it('propagates registration failures other than mid-teardown', async () => { - const dir = tmp() - const personal = tmp() - const ctx = await boot(NAME, writeTree(dir)) - try { - await ctx.plugin(Timer) - await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 }) - const dispose = await watchPersonalPatches(ctx, { binName: NAME, dir: personal }) - // Same personal path registered twice: HMR refuses; not a teardown race. - await expect(watchPersonalPatches(ctx, { binName: NAME, dir: personal })).rejects.toThrow('already registered') - await dispose() - } finally { - await ctx.fiber.dispose() - } - }) -}) From 0512b12714634ffcdddef1df741e34c2fed53cb7 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 4 Aug 2026 16:17:32 +0800 Subject: [PATCH 04/27] feat(config)!: one ordering for configuration sources, and a bootstrap deny rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit $DSH_HOME/.env had just become an ordinary environment layer, which left the harness resolving user-facing values from a flattened process.env that could no longer say where a value came from. A key stored through the web page stayed shadowed by an older key in the user's own .env. An endpoint could be redirected by the project: the invoking directory's .env is materialized like every other layer, and a base URL decides where a resolved API key is sent, so a DEEPSEEK_BASE_URL written into a model-editable workspace would send the user's credential — and the prompts carrying their code — to whatever host that file named. Give every user-facing value one ordering, with four kinds of source: explicit for this run per-operation override, CLI argument > authored by deployment --config / --config-replace > this launch's shell inherited process environment > product-managed store settings.yaml, .credentials.yaml > discovered file $DSH_HOME/.env > defaults schema default, shipped base, public default The domains differ only in which tiers exist. The earlier split — credentials ranking the environment over the managed file while settings ranked over the environment — was inconsistent: the distinguishing fact is who authored the source, not the domain. packages/util/environment owns an immutable snapshot with per-layer provenance. getFrom(name, sources) searches only the layers a caller names, and omitting one is a refusal rather than a demotion: the adapters ask for ['process', 'user-env'], so no reordering can let a project file back into a decision it was excluded from. isBootstrapOnly rejects, before anything is materialized, any .env setting a variable that governs how a process launches (PATH, SHELL, NODE_OPTIONS, LD_PRELOAD), where code or model-visible instructions load from (the whole DSH_* namespace, HOME, XDG_*), or how the network is reached (proxy and CA variables). The namespace is denied wholesale so a switch added later cannot become settable by being forgotten, and there is no opt-out. verify-config-source-ownership keeps both rules: no unregistered process.env read under packages/*/*/src (26 allowlisted with reasons), and no apiKey, baseURL, or headers inlined from the environment in shipped Cordis config — removing those inlines is what makes the deployment tier meaningful. --- ...4-configuration-source-ownership.i18n.yaml | 6 + ...26-08-04-configuration-source-ownership.md | 63 +++++++ ...08-04-configuration-source-ownership.zh.md | 65 +++++++ THIRD_PARTY_NOTICES.md | 1 + apps/cli/config/base.cordis.yml | 1 - apps/cli/config/tui.cordis.yml | 2 - apps/cli/config/web.cordis.yml | 5 - apps/cli/package.json | 3 +- apps/cli/src/app-cli-entry.ts | 6 + apps/cli/src/bin.ts | 15 +- apps/cli/src/headless.ts | 7 +- apps/cli/src/tui.ts | 5 + apps/cli/src/web.ts | 4 + apps/cli/tests/tui-keyless-smoke.e2e.ts | 12 +- apps/cli/tsconfig.json | 3 + docs/config-catalog.md | 13 +- examples/acp-agent/cordis.yml | 2 - examples/acp-agent/retry.cordis.yml | 2 - examples/jsonrpc-agent/cordis.yml | 2 - .../jsonrpc-agent/persistent-tools.cordis.yml | 2 - package.json | 157 +++++++-------- .../credentials-local/package.json | 2 + .../credentials-local/src/index.ts | 75 ++++++-- .../credentials-local/tests/local.spec.ts | 69 +++++++ .../credentials-local/tsconfig.json | 3 + packages/llm/llm-deepseek/package.json | 2 + packages/llm/llm-deepseek/src/index.ts | 28 ++- .../llm/llm-deepseek/tests/adapter.spec.ts | 22 ++- packages/llm/llm-deepseek/tsconfig.json | 3 + packages/llm/llm-pi-ai/package.json | 2 + packages/llm/llm-pi-ai/src/index.ts | 7 +- packages/llm/llm-pi-ai/tsconfig.json | 3 + packages/ui/app-boot/package.json | 3 + packages/ui/app-boot/src/index.ts | 76 +++++++- packages/ui/app-boot/tests/app-boot.spec.ts | 64 ++++++- packages/ui/app-boot/tsconfig.json | 3 + packages/util/environment/README.i18n.yaml | 6 + packages/util/environment/README.md | 42 +++++ packages/util/environment/README.zh.md | 42 +++++ packages/util/environment/package.json | 37 ++++ packages/util/environment/src/index.ts | 178 ++++++++++++++++++ packages/util/environment/src/invariant.ts | 30 +++ .../environment/tests/environment.spec.ts | 118 ++++++++++++ packages/util/environment/tsconfig.json | 15 ++ packages/web/web-search-deepseek/package.json | 2 + packages/web/web-search-deepseek/src/index.ts | 7 +- .../web/web-search-deepseek/tsconfig.json | 3 + packages/web/web-search-exa/package.json | 2 + packages/web/web-search-exa/src/index.ts | 6 +- packages/web/web-search-exa/tsconfig.json | 3 + .../web/web-search-perplexity/package.json | 2 + .../web/web-search-perplexity/src/index.ts | 6 +- .../web/web-search-perplexity/tsconfig.json | 3 + pnpm-lock.yaml | 45 +++++ python/sdk-runtime/package.json | 1 + scripts/run-gates.ts | 1 + scripts/verify-config-source-ownership.ts | 117 ++++++++++++ .../verify-package-readme-model-experience.ts | 1 + tsconfig.host.json | 1 + 59 files changed, 1241 insertions(+), 165 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md create mode 100644 .agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md create mode 100644 packages/util/environment/README.i18n.yaml create mode 100644 packages/util/environment/README.md create mode 100644 packages/util/environment/README.zh.md create mode 100644 packages/util/environment/package.json create mode 100644 packages/util/environment/src/index.ts create mode 100644 packages/util/environment/src/invariant.ts create mode 100644 packages/util/environment/tests/environment.spec.ts create mode 100644 packages/util/environment/tsconfig.json create mode 100644 scripts/verify-config-source-ownership.ts diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml new file mode 100644 index 0000000000..7ff8cfa74c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.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-08-04-configuration-source-ownership.md +2026-08-04-configuration-source-ownership.md: f19067abb899e41742f88ce6d17623bc5b82d008 +2026-08-04-configuration-source-ownership.zh.md: a5fd7c61ee71eb9ed9184c3f9c557fb1c3b951ad diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md new file mode 100644 index 0000000000..f19067abb8 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md @@ -0,0 +1,63 @@ +# Agent Note: One ordering for configuration sources, and what a discovered file may not decide + +Status: implemented + +English | [中文](2026-08-04-configuration-source-ownership.zh.md) + +## Problem + +`$DSH_HOME/.env` had just [become an ordinary environment layer](2026-08-04-credentials-yaml-and-user-environment-layer.md), which left the harness resolving user-facing values from a flattened `process.env` that could no longer say where a value came from. Three consequences followed. + +A key stored through the web page stayed shadowed by an older key in the user's own `.env`, because the credential provider compared "the environment" against its file and the environment now included that file. The migration dead end the split was supposed to remove had simply moved. + +An endpoint could be redirected by the project. The invoking directory's `.env` is materialized like every other layer, and a base URL decides where a resolved API key is sent — so a `DEEPSEEK_BASE_URL` written into a workspace the model can edit would send the user's own credential, and the prompts carrying their code, to whatever host that file named. Nothing about the flattened view could distinguish that from the operator exporting the same variable. + +And `!!js process.env.X` in the shipped composition made the same value reachable twice: once through the entry config and once through whatever ladder its consumer applied, with the winner decided by layer order rather than by what the value means. + +## Decision + +**One ordering, four kinds of source.** Every user-facing value resolves in the same order; the domains differ only in which tiers exist. + +```text +explicit for this run per-operation override, CLI argument +> authored by deployment --config / --config-replace +> this launch's shell inherited process environment +> product-managed store settings.yaml, .credentials.yaml +> discovered file $DSH_HOME/.env +> defaults schema default, shipped base, provider public default +``` + +Credentials have no deployment tier (configuration carries a reference, never a value) and no default. Endpoints have every tier. Model selection has CLI, settings, and the shipped default. The earlier proposal ranked a UI-written credential *below* the environment while ranking UI-written settings *above* it; the distinguishing fact is not the domain but who authored the file, so `.credentials.yaml` and `settings.yaml` now sit together, both under the launching shell and both over a discovered `.env`. + +**The invoking directory's `.env` decides no credential and no route.** `EnvironmentSnapshot.getFrom(name, sources)` searches only the layers a caller names, and omitting one is a refusal rather than a demotion: the adapters ask for `['process', 'user-env']`, so no future reordering can let a project file back into a decision it was excluded from. A project `.env` remains an ordinary environment layer for ordinary variables. + +**A discovered file may not decide how the process starts.** `isBootstrapOnly` rejects, at load and before anything is materialized, any `.env` that sets a variable governing how a process launches (`PATH`, `SHELL`, `NODE_OPTIONS`, `LD_PRELOAD`, …), where code or model-visible instructions load from (the whole `DSH_*` namespace, `HOME`, `XDG_*`), or how the network is reached and trusted (proxy and CA variables). Matching is case-insensitive, so `https_proxy` is not a bypass. + +The whole `DSH_*` namespace is denied rather than an audited subset. The harness's own switches — the permission mode, the agents home that holds model-visible skills, the bundled skill root — are exactly what a hostile project would reach for, and a switch added later must not become settable by being forgotten. There is no opt-out: an escape hatch would have to be readable from somewhere, and anything a discovered file could set is the hole itself. + +**`packages/util/environment` owns the snapshot**, deliberately as a utility rather than a three-package capability seam. The snapshot is frozen before Cordis starts and injected once by the launcher, so there is no runtime implementation to swap; consumers need types and pure functions, which a `util/` package gives them without depending on a UI package. `environmentOf(ctx)` returns the launcher's snapshot, or the inherited environment as the only layer — an SDK host or bare `cordis.yml` discovered no files, so its single layer really is what it was launched with, and the same trusted lookups keep working there unchanged. + +**`verify-config-source-ownership`** keeps both rules: no unregistered `process.env` read under `packages/*/*/src` (26 allowlisted, each with the reason it is a process fact), and no `apiKey`/`baseURL`/`headers` inlined from the environment in shipped Cordis configuration. Removing those inlines is what makes the deployment tier meaningful — with the shipped tree silent on `baseURL`, a present value means a human or deployment set it. + +## Consequences + +- The web credential form now takes effect against an older key in the user's `.env`; only a key exported in the launching shell still makes it read-only, and the diagnostic says so. +- A `.env` holding `DSH_*`, `PATH`, or a proxy variable fails the launch instead of being applied. Developers keeping switches in a repository `.env` move them to their shell — a deliberate, loud break. +- `--config` is no longer overridable by a stale shell endpoint, so a deployment can pin an enterprise gateway. +- Given up: an endpoint or key in the invoking directory's `.env` no longer applies. Per-project routing is a `--config` overlay or an `export` in that project's shell. +- Not solved: the layers are still materialized into `process.env`, so ordinary project variables continue to reach child processes under the subprocess scrub. Bootstrap variables cannot come from a file at all, which closes the escalation path; a project `.env` setting something like `GIT_SSH_COMMAND` for the tools an agent runs remains possible and is recorded as a limitation on the package. +- Exa and Perplexity still capture their key at load time rather than through the credential seam. They no longer read raw `process.env` — they resolve through the trusted layers — but converting them to per-request seam resolution is separate work. + +## Alternatives considered + +**Keep the proposal's split ladders (credentials env-over-file, endpoints settings-over-env).** Rejected on its own inconsistency: both arguments — "an export is this run's intent" and "a deployment's file should not be rewritten by a stale shell" — apply to both domains. Sorting by *who authored the source* explains both and produces one table instead of four. + +**Let the invoking directory's `.env` supply a credential, ranked below the managed store.** Rejected: with no key stored, a hostile project's key would be used silently, and the account holder reads every prompt sent under it. That is the same exfiltration the endpoint rule exists to prevent, so it takes the same answer. + +**Audit an allowlist of `DSH_*` variables a `.env` may set.** Rejected: the list would have to be re-audited on every new switch, and the failure mode of forgetting is silent. Denying the namespace fails safe. + +**Rank a bootstrap variable below the process layer instead of rejecting it.** Rejected: `PATH` and `NODE_OPTIONS` have no meaningful "loser" behavior — a user who put one in a `.env` believes it applies, and silently ignoring it is the "my setting has no effect" failure this whole series exists to remove. + +**Build the snapshot as a three-package capability seam (`environment` / `environment-local` / consumers).** Rejected as premature: the producer runs before Cordis exists and there is no second implementation to select. The repository rule is to not split preemptively. + +**Stop materializing the layers into `process.env`.** Deferred, not rejected: it would keep project variables out of child processes entirely, but it silently breaks any user `--config` tree that reads `!!js process.env.X`. The snapshot is already the authority for everything the harness resolves, so this can land later without changing any ladder. diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md new file mode 100644 index 0000000000..a5fd7c61ee --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md @@ -0,0 +1,65 @@ +# Agent Note: 配置来源的统一顺序,以及被发现的文件不得决定什么 + +Status: implemented + +[English](2026-08-04-configuration-source-ownership.md) | 中文 + +## Problem + +`$DSH_HOME/.env` 刚刚[变成普通环境层](2026-08-04-credentials-yaml-and-user-environment-layer.md),这使得 harness 解析面向用户的值时面对的是一个压平的 `process.env`,再也说不清某个值来自哪里。由此产生三个后果。 + +通过 Web 页面存下的密钥仍然被用户自己 `.env` 里更旧的密钥遮蔽,因为凭据 provider 是拿「环境」与自己的文件比较,而现在环境包含了那个文件。这次拆分本该消除的迁移死路,只是换了个位置。 + +endpoint 可以被项目重定向。调用目录的 `.env` 和其他层一样会被物化,而 base URL 决定已解析的 API key 发往何处——于是写进模型可编辑工作区的 `DEEPSEEK_BASE_URL`,会把用户自己的凭据、以及承载其代码的提示词,一起发给该文件指定的任何主机。压平的视图无法把这件事和运维显式 export 同一个变量区分开。 + +而已交付组合里的 `!!js process.env.X` 让同一个值有两条抵达路径:一条经 entry config,一条经消费方各自的 ladder,胜负取决于层序而非这个值的语义。 + +## Decision + +**一条顺序,四类来源。** 每个面向用户的值按同一顺序解析;各领域的差别只在于哪些层存在。 + +```text +explicit for this run per-operation override, CLI argument +> authored by deployment --config / --config-replace +> this launch's shell inherited process environment +> product-managed store settings.yaml, .credentials.yaml +> discovered file $DSH_HOME/.env +> defaults schema default, shipped base, provider public default +``` + +自上而下依次是:本次运行的显式意图、部署授权、本次启动的 shell、产品受管存储、被发现的文件、默认值。 + +凭据没有部署层(配置携带引用,从不携带值),也没有默认值层。endpoint 拥有全部层。模型选择只有 CLI、settings 与已交付默认值。此前的方案把 UI 写入的凭据排在环境*之下*,却把 UI 写入的 settings 排在环境*之上*;真正的区分依据不是领域,而是这个文件由谁书写,因此 `.credentials.yaml` 与 `settings.yaml` 现在并列,同在启动 shell 之下、同在被发现的 `.env` 之上。 + +**调用目录的 `.env` 不决定任何凭据与路由。** `EnvironmentSnapshot.getFrom(name, sources)` 只搜索调用方点名的层,省略某层是拒绝而不是降级:适配器请求的是 `['process', 'user-env']`,因此后续任何重新排序都无法让项目文件重新进入一个它被排除在外的决策。对普通变量而言,项目 `.env` 仍然是普通环境层。 + +**被发现的文件不得决定进程如何启动。** `isBootstrapOnly` 会在加载时、且在物化任何内容之前,拒绝任何设置了下列变量的 `.env`:决定进程如何启动的(`PATH`、`SHELL`、`NODE_OPTIONS`、`LD_PRELOAD` 等)、决定代码或模型可见指令从哪里加载的(整个 `DSH_*` 命名空间、`HOME`、`XDG_*`),以及决定网络如何抵达与信任的(proxy 与 CA 变量)。匹配不区分大小写,因此 `https_proxy` 不是绕过手段。 + +被拒绝的是整个 `DSH_*` 命名空间,而不是一份经过审查的子集。harness 自己的开关——权限模式、存放模型可见 skill(技能)的 agents home、内置 skill 根目录——恰恰是敌意项目最想伸手的地方,而后来新增的开关不能因为被遗忘就变得可设置。不设逃生门:逃生门本身总得从某处读取,而任何被发现的文件能设置的东西,就是那个漏洞本身。 + +**`packages/util/environment` 拥有该快照**,刻意做成 utility 而不是三包能力 seam。快照在 Cordis 启动前就冻结,并由启动器一次性注入,因此不存在需要切换的运行时实现;消费方需要的只是类型和纯函数,而 `util/` 包能提供这些且不必依赖 UI 包。`environmentOf(ctx)` 返回启动器的快照,或者返回只含继承环境的那一层——SDK 宿主或裸 `cordis.yml` 从未发现过任何文件,它那唯一一层确实就是它被启动时的环境,因此同样的受信查询在那里原样继续工作。 + +**`verify-config-source-ownership`** 守住这两条规则:`packages/*/*/src` 下没有未登记的 `process.env` 读取(26 处在 allowlist 中,各自写明它为何是进程事实),以及已交付 Cordis 配置中不得从环境内联 `apiKey`/`baseURL`/`headers`。删除这些内联正是「部署层」得以成立的原因——已交付配置树对 `baseURL` 保持沉默之后,「有值」就意味着「人或部署设过它」。 + +## Consequences + +- Web 凭据表单现在能压过用户 `.env` 里更旧的密钥;只有在启动 shell 里 export 的密钥才会让它变成只读,诊断信息也会这么说。 +- 含 `DSH_*`、`PATH` 或 proxy 变量的 `.env` 会导致启动失败而不是被应用。把开关放在仓库 `.env` 里的开发者需要改放到 shell——这是一次刻意且响亮的破坏。 +- `--config` 不再会被陈旧的 shell endpoint 覆盖,因此部署方可以钉住企业网关。 +- 放弃的:调用目录 `.env` 里的 endpoint 或密钥不再生效。按项目切换路由请用 `--config` overlay 或该项目 shell 里的 `export`。 +- 未解决的:各层仍然会被物化进 `process.env`,因此普通项目变量继续按子进程清洗规则抵达子进程。bootstrap 变量完全不能来自文件,提权路径已封闭;项目 `.env` 为 agent 运行的工具设置诸如 `GIT_SSH_COMMAND` 之类的变量仍然可能,已作为限制记录在该包上。 +- Exa 与 Perplexity 仍在加载时捕获密钥,而不是经凭据 seam。它们不再读裸 `process.env`——改为经受信层解析——但把它们改造成按请求经 seam 解析是另一件事。 + +## Alternatives considered + +**沿用方案里分开的两条 ladder(凭据环境压过文件、endpoint settings 压过环境)。** 因其自身的不自洽而否决:两条理由——「export 是本次运行的意图」和「部署方的文件不该被陈旧 shell 改写」——对两个领域同样成立。按*来源由谁书写*排序能同时解释两者,并且把四张表变成一张。 + +**允许调用目录 `.env` 提供凭据,排在受管存储之下。** 否决:在没有存储密钥时,敌意项目的密钥会被静默使用,而该账号持有者能读到以它发出的每一条提示词。这与 endpoint 规则要防的外泄是同一件事,因此答案也相同。 + +**审查出一份 `.env` 可设置的 `DSH_*` 白名单。** 否决:每新增一个开关都要重新审查,而遗漏的失败模式是静默的。拒绝整个命名空间是 fail safe。 + +**把 bootstrap 变量排在 process 层之下,而不是拒绝它。** 否决:`PATH` 和 `NODE_OPTIONS` 没有有意义的「输了之后」行为——把它写进 `.env` 的用户认为它生效,而静默忽略正是整个系列要消除的那种「我的设置没有效果」。 + +**把快照做成三包能力 seam(`environment` / `environment-local` / 消费方)。** 作为过早拆分而否决:生产方在 Cordis 存在之前就运行,也没有第二个实现需要选择。仓库规则是不要预先拆分。 + +**不再把各层物化进 `process.env`。** 延后而非否决:它能让项目变量彻底进不了子进程,但会静默破坏任何读 `!!js process.env.X` 的用户 `--config` 树。快照已经是 harness 解析一切的依据,因此这件事以后落地也不改变任何 ladder。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 515004086e..92ea0d2406 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -52,6 +52,7 @@ External packages that a workspace package resolves at runtime. `scripts/install | [`clsx`](https://github.com/lukeed/clsx) | MIT | | [`commander`](https://github.com/tj/commander.js) | MIT | | [`diff`](https://github.com/kpdecker/jsdiff) | BSD-3-Clause | +| [`dotenv`](https://github.com/motdotla/dotenv) | BSD-2-Clause | | [`eventsource-parser`](https://github.com/rexxars/eventsource-parser) | MIT | | [`handlebars`](https://github.com/handlebars-lang/handlebars.js) | MIT | | [`immer`](https://github.com/immerjs/immer) | MIT | diff --git a/apps/cli/config/base.cordis.yml b/apps/cli/config/base.cordis.yml index aea2f8934c..9985e8fbd0 100644 --- a/apps/cli/config/base.cordis.yml +++ b/apps/cli/config/base.cordis.yml @@ -360,7 +360,6 @@ name: '@deepseek-ai/dsh-web-search-deepseek' config: apiKeyEnv: DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_SEARCH_BASE_URL - id: tool-web name: '@deepseek-ai/dsh-tool-web' diff --git a/apps/cli/config/tui.cordis.yml b/apps/cli/config/tui.cordis.yml index 02d8649447..a3118a5419 100644 --- a/apps/cli/config/tui.cordis.yml +++ b/apps/cli/config/tui.cordis.yml @@ -40,8 +40,6 @@ # resolution materializes request defaults before the request header is logged. - id: llm-deepseek config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_BASE_URL thinking: enabled reasoningEffort: max diff --git a/apps/cli/config/web.cordis.yml b/apps/cli/config/web.cordis.yml index efd2f93b2a..7dc72708c3 100644 --- a/apps/cli/config/web.cordis.yml +++ b/apps/cli/config/web.cordis.yml @@ -36,11 +36,6 @@ # once the web UI owns the choice per session. mode: !!js process.env.DSH_TOOLS_MODE -- id: llm-deepseek - config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_BASE_URL - # ── web-only host rows, the transport layer, and the browser roster ───────── # `dshClient` rows are the browser roster the modules node half scans into diff --git a/apps/cli/package.json b/apps/cli/package.json index 8c482ac3e2..7d67e704b9 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -54,6 +54,7 @@ "@deepseek-ai/dsh-compact-basic": "workspace:^", "@deepseek-ai/dsh-compact-tool-result-prune": "workspace:^", "@deepseek-ai/dsh-credentials-local": "workspace:^", + "@deepseek-ai/dsh-environment": "workspace:^", "@deepseek-ai/dsh-frontend": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-fs-policy": "workspace:^", @@ -74,9 +75,9 @@ "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-permission": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", - "@deepseek-ai/dsh-repeat-tool-guard": "workspace:^", "@deepseek-ai/dsh-pty": "workspace:^", "@deepseek-ai/dsh-pty-local": "workspace:^", + "@deepseek-ai/dsh-repeat-tool-guard": "workspace:^", "@deepseek-ai/dsh-repository-plugin": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", diff --git a/apps/cli/src/app-cli-entry.ts b/apps/cli/src/app-cli-entry.ts index 95776484d0..6b072e8aeb 100644 --- a/apps/cli/src/app-cli-entry.ts +++ b/apps/cli/src/app-cli-entry.ts @@ -14,6 +14,7 @@ import { createRequire } from 'node:module' import { networkInterfaces } from 'node:os' import { resolve } from 'node:path' import { Context } from 'cordis' +import { DSH_ENVIRONMENT_KEY, type EnvironmentSnapshot } from '@deepseek-ai/dsh-environment' import type { PatchOptions } from '@cordisjs/plugin-include' import yaml from 'js-yaml' import { boot, installFailLoud, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot' @@ -102,6 +103,8 @@ const includeYamlSchema = yaml.JSON_SCHEMA.extend(jsExprType) /** Constructor facts for one dsh invocation over the shared composition (argv already parsed by the surface bin). */ export interface AppCLIEntryOptions { + /** This run's frozen environment, provided to the tree before any config entry mounts. */ + environment: EnvironmentSnapshot /** Absolute path of the shared base config the Loader includes. */ configPath: string /** @@ -255,6 +258,9 @@ export class AppCLIEntry { ...this.patches, ] this.ctx = await boot('dsh', resolve(this.bootConfigPath()), patches, async (ctx) => { + // Before any config-tree entry mounts, so a plugin that resolves a + // user-facing value at construction already sees this run's layers. + ctx.provide(DSH_ENVIRONMENT_KEY, this.options.environment) await this.options.prepare?.(ctx) if (this.options.dev) await ctx.loader.create({ name: '@deepseek-ai/dsh-client-hmr' }) }) diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts index bdef3205b9..ae00d6b168 100644 --- a/apps/cli/src/bin.ts +++ b/apps/cli/src/bin.ts @@ -24,24 +24,27 @@ function readVersion(): string { return typeof manifest.version === 'string' ? manifest.version : '0.0.0' } -loadLayeredEnv('dsh') +const environment = loadLayeredEnv('dsh') // The env opt-in is read at the process boundary; `1` is the documented value. const invocation = parseDshArgs(process.argv.slice(2), readVersion(), process.env.DSH_EXPERIMENTAL === '1') switch (invocation.mode) { case 'web': { const { runWeb } = await import('./web.ts') - await runWeb(invocation.host, invocation.port, invocation.dev, invocation.workspaceRoot, invocation.trustedHosts, invocation.config) + await runWeb( + environment, invocation.host, invocation.port, invocation.dev, + invocation.workspaceRoot, invocation.trustedHosts, invocation.config, + ) break } case 'headless': { const { runHeadless } = await import('./headless.ts') - await runHeadless(invocation.prompt, invocation.config, invocation.configReplace) + await runHeadless(environment, invocation.prompt, invocation.config, invocation.configReplace) break } case 'tui': { const { runTui } = await import('./tui.ts') - await runTui(invocation.config, invocation.resume, undefined, undefined, invocation.configReplace) + await runTui(environment, invocation.config, invocation.resume, undefined, undefined, invocation.configReplace) break } case 'dump-config': { @@ -51,12 +54,12 @@ switch (invocation.mode) { } case 'meta': { const { runTui, SOURCE_ROOT } = await import('./tui.ts') - await runTui(invocation.config, undefined, SOURCE_ROOT, undefined, invocation.configReplace) + await runTui(environment, invocation.config, undefined, SOURCE_ROOT, undefined, invocation.configReplace) break } case 'upgrade': { const { runTui } = await import('./tui.ts') - await runTui(invocation.config, undefined, undefined, `dsh-${invocation.mode}`, invocation.configReplace) + await runTui(environment, invocation.config, undefined, undefined, `dsh-${invocation.mode}`, invocation.configReplace) break } default: diff --git a/apps/cli/src/headless.ts b/apps/cli/src/headless.ts index 5864604e05..098992f181 100644 --- a/apps/cli/src/headless.ts +++ b/apps/cli/src/headless.ts @@ -10,6 +10,7 @@ import { fileURLToPath } from 'node:url' import { resolveConfigPath } from '@deepseek-ai/dsh-app-boot' +import type { EnvironmentSnapshot } from '@deepseek-ai/dsh-environment' import { InProcessApiClient, toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy' import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api' import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' @@ -71,15 +72,19 @@ async function consumeUntilTurnEnd(frames: AsyncIterable>, * Run one headless turn for `task` and exit (completed → 0, else 1). The task * is the non-empty prompt the argument adapter parsed from `-p`/`--prompt` * (the adapter rejects an empty task, so no guard is needed here). + * @param environment - this run's frozen environment snapshot. * @param task - the prompt text for the single turn. * @param config - a `--config` overlay applied over the shipped composition, or `undefined`. * @param configReplace - a `--config-replace` tree booted instead of the * shipped composition, or `undefined`. It must mount a webserver row: this * surface reaches its own agent over the same HTTP gateway the browser uses. */ -export async function runHeadless(task: string, config?: string, configReplace?: string): Promise { +export async function runHeadless( + environment: EnvironmentSnapshot, task: string, config?: string, configReplace?: string, +): Promise { // A missing DEEPSEEK_API_KEY throws here (plugin load is fail-loud, uncaught by design). const entry = new AppCLIEntry({ + environment, configPath: fileURLToPath(new URL('../config/base.cordis.yml', import.meta.url)), overlayPath: fileURLToPath(new URL('../config/web.cordis.yml', import.meta.url)), ...config !== undefined && { extraOverlayPath: resolveConfigPath(config, undefined) }, diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index 20981dc068..6d36b0faa8 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -29,6 +29,7 @@ import { resolveConfigPath, } from '@deepseek-ai/dsh-app-boot' import { resolveDshHome } from '@deepseek-ai/dsh-paths' +import { DSH_ENVIRONMENT_KEY, type EnvironmentSnapshot } from '@deepseek-ai/dsh-environment' import type { PatchOptions } from '@cordisjs/plugin-include' import { SessionId } from '@deepseek-ai/dsh-session' import { configHasTelemetryRow, resolveTelemetryPatch } from './app-cli-entry.ts' @@ -78,6 +79,8 @@ export const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) the CLI PTY smoke drives this path end to end, --config overlay included */ /** * Run the interactive TUI from the invoking directory. + * @param environment - this run's frozen environment snapshot, provided to the + * tree before any config entry mounts. * @param config - an overlay patch list applied over the shared base and the * TUI overlay, or `undefined` for the shipped composition alone; already * parsed from `--config`. @@ -97,6 +100,7 @@ export const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) * already parsed from `--config-replace`. */ export async function runTui( + environment: EnvironmentSnapshot, config: string | undefined, resumeSessionId: string | undefined, workspace?: string, @@ -225,6 +229,7 @@ export async function runTui( // Runs after the Loader installs and before any config-tree entry mounts, // so the fail-loud release hook can reach the tree for the whole window in // which an entry may reject. + hostCtx.provide(DSH_ENVIRONMENT_KEY, environment) app.current = hostCtx // The launcher owns session identity and the exit line: a config-mounted // app bundle reads both from these slots, so no cordis.yml key can drop diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index a3dc446706..fcab06f54b 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -12,6 +12,7 @@ import { addHarnessSourceSection, resolveConfigPath } from '@deepseek-ai/dsh-app import type {} from '@deepseek-ai/dsh-host-webserver' import type {} from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-tool-bash' +import type { EnvironmentSnapshot } from '@deepseek-ai/dsh-environment' import { AppCLIEntry } from './app-cli-entry.ts' // The shared core every `dsh` surface mounts, plus this surface's overlay over it. @@ -85,6 +86,7 @@ export function prepareWebRuntimeContext(ctx: Context, sourceRoot: string, mode: /** * Serve the browser UI from the shipped config tree. `host`/`port` are passed * through only when the flag was given; absent, the shipped Web overlay value stands. + * @param environment - this run's frozen environment snapshot. * @param host - the bind host, or `undefined` to keep the config default. * @param port - the listen port (`0` requests an OS-assigned port), or `undefined` to keep the config default. * @param dev - mount the client HMR receiver; `pnpm run dev:web` separately rebuilds watched plugin bundles. @@ -95,6 +97,7 @@ export function prepareWebRuntimeContext(ctx: Context, sourceRoot: string, mode: * personal overlay; already parsed from `--config`. */ export async function runWeb( + environment: EnvironmentSnapshot, host: string | undefined, port: number | undefined, dev: boolean, @@ -104,6 +107,7 @@ export async function runWeb( ): Promise { const mode: WebMode = dev ? 'development' : 'production' const entry = new AppCLIEntry({ + environment, configPath: BASE_CONFIG, overlayPath: WEB_OVERLAY, ...config !== undefined && { extraOverlayPath: resolveConfigPath(config, undefined) }, diff --git a/apps/cli/tests/tui-keyless-smoke.e2e.ts b/apps/cli/tests/tui-keyless-smoke.e2e.ts index 2894d0a1ca..94366f4c61 100644 --- a/apps/cli/tests/tui-keyless-smoke.e2e.ts +++ b/apps/cli/tests/tui-keyless-smoke.e2e.ts @@ -672,9 +672,9 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { // layering underneath it. The named file 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 `!!js` expression - // renders both halves of the layering in one line: `DSH_LAYER_WELCOME` is + // renders both halves of the layering in one line: `OVERLAY_LAYER_WELCOME` is // set by BOTH .env files and must render the project value, while - // `DSH_USER_ONLY` exists only in the harness home's .env and must still + // `OVERLAY_USER_ONLY` exists only in the harness home's .env and must still // arrive. Credentials are not part of this: they live in // `.credentials.yaml`, which is never hoisted into `process.env`. const output = await smoke({ @@ -683,17 +683,17 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { binScript: dshBinScript, configArgs: ['--config', '.dsh/config.yaml'], prepare: seedWorkspace({ - workspace: { '.env': 'DSH_LAYER_WELCOME=PROJECT WINS.\n' }, + workspace: { '.env': 'OVERLAY_LAYER_WELCOME=PROJECT WINS.\n' }, harnessHome: { - '.env': 'DSH_LAYER_WELCOME=USER LAYER LOST.\nDSH_USER_ONLY=USER LAYER LOADED.\n', + '.env': 'OVERLAY_LAYER_WELCOME=USER LAYER LOST.\nOVERLAY_USER_ONLY=USER LAYER LOADED.\n', 'config.yaml': [ '- id: workspace-context', ' disabled: true', '- id: tui', ' config:', " sessionId: !!js configuredAgentIdentities?.main?.id ?? 'main'", - ' welcome: !!js "(process.env.DSH_LAYER_WELCOME ?? \'PROJECT LAYER MISSING.\')' - + ' + \' \' + (process.env.DSH_USER_ONLY ?? \'USER LAYER MISSING.\')"', + ' welcome: !!js "(process.env.OVERLAY_LAYER_WELCOME ?? \'PROJECT LAYER MISSING.\')' + + ' + \' \' + (process.env.OVERLAY_USER_ONLY ?? \'USER LAYER MISSING.\')"', '', ].join('\n'), }, diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index 2f995abf87..77da8d2bff 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -29,6 +29,9 @@ { "path": "../../packages/ui/tui" }, + { + "path": "../../packages/util/environment" + }, { "path": "../../packages/util/paths" }, diff --git a/docs/config-catalog.md b/docs/config-catalog.md index ab0ca22024..44888f182a 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -423,7 +423,7 @@ export interface Config { } ``` -Source: [`packages/credentials/credentials-local/src/index.ts:35`](../packages/credentials/credentials-local/src/index.ts) +Source: [`packages/credentials/credentials-local/src/index.ts:54`](../packages/credentials/credentials-local/src/index.ts) ## `@deepseek-ai/dsh-fs-local` @@ -632,7 +632,7 @@ export interface Config { apiKey?: string /** Credential reference (environment-variable name) resolved per request; defaults to `DEEPSEEK_API_KEY`. */ apiKeyEnv?: string - /** Endpoint base; falls back to $DEEPSEEK_BASE_URL, then the public API. */ + /** Endpoint base; falls back to $DEEPSEEK_BASE_URL from a trusted environment layer, then the public API. */ baseURL?: string /** Deployment thinking policy; `disabled` limits every conversation request to `off`. */ thinking?: 'enabled' | 'disabled' @@ -665,7 +665,7 @@ export interface DeepSeekCatalogModel { Depends on: [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) -Source: [`packages/llm/llm-deepseek/src/index.ts:60`](../packages/llm/llm-deepseek/src/index.ts) +Source: [`packages/llm/llm-deepseek/src/index.ts:61`](../packages/llm/llm-deepseek/src/index.ts) ## `@deepseek-ai/dsh-llm-pi-ai` @@ -2229,7 +2229,7 @@ export interface Config { } ``` -Source: [`packages/web/web-search-deepseek/src/index.ts:43`](../packages/web/web-search-deepseek/src/index.ts) +Source: [`packages/web/web-search-deepseek/src/index.ts:44`](../packages/web/web-search-deepseek/src/index.ts) ## `@deepseek-ai/dsh-web-search-exa` @@ -2251,7 +2251,7 @@ export interface Config { } ``` -Source: [`packages/web/web-search-exa/src/index.ts:37`](../packages/web/web-search-exa/src/index.ts) +Source: [`packages/web/web-search-exa/src/index.ts:38`](../packages/web/web-search-exa/src/index.ts) ## `@deepseek-ai/dsh-web-search-perplexity` @@ -2273,7 +2273,7 @@ export interface Config { } ``` -Source: [`packages/web/web-search-perplexity/src/index.ts:31`](../packages/web/web-search-perplexity/src/index.ts) +Source: [`packages/web/web-search-perplexity/src/index.ts:32`](../packages/web/web-search-perplexity/src/index.ts) ## `@deepseek-ai/dsh-workflow-workerthread` @@ -2417,6 +2417,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-client-ui-slots` ([`packages/client/ui-slots/src/index.ts`](../packages/client/ui-slots/src/index.ts)) - `@deepseek-ai/dsh-client-web` ([`packages/client/web/src/index.ts`](../packages/client/web/src/index.ts)) - `@deepseek-ai/dsh-client-web-react` ([`packages/client/web-react/src/index.ts`](../packages/client/web-react/src/index.ts)) +- `@deepseek-ai/dsh-environment` ([`packages/util/environment/src/index.ts`](../packages/util/environment/src/index.ts)) - `@deepseek-ai/dsh-helper` ([`packages/sdk/helper/src/index.ts`](../packages/sdk/helper/src/index.ts)) - `@deepseek-ai/dsh-hook-protocol` ([`packages/hooks/hook-protocol/src/index.ts`](../packages/hooks/hook-protocol/src/index.ts)) - `@deepseek-ai/dsh-jsonrpc-demo` ([`packages/examples/jsonrpc-demo/src/index.ts`](../packages/examples/jsonrpc-demo/src/index.ts)) diff --git a/examples/acp-agent/cordis.yml b/examples/acp-agent/cordis.yml index 6edcee5cd8..8aaf690002 100644 --- a/examples/acp-agent/cordis.yml +++ b/examples/acp-agent/cordis.yml @@ -9,8 +9,6 @@ - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_BASE_URL thinking: enabled reasoningEffort: max models: diff --git a/examples/acp-agent/retry.cordis.yml b/examples/acp-agent/retry.cordis.yml index 589120c080..087faa271d 100644 --- a/examples/acp-agent/retry.cordis.yml +++ b/examples/acp-agent/retry.cordis.yml @@ -13,8 +13,6 @@ - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_BASE_URL thinking: enabled reasoningEffort: max retryPolicy: diff --git a/examples/jsonrpc-agent/cordis.yml b/examples/jsonrpc-agent/cordis.yml index 9806413725..9e9d908593 100644 --- a/examples/jsonrpc-agent/cordis.yml +++ b/examples/jsonrpc-agent/cordis.yml @@ -12,8 +12,6 @@ - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_BASE_URL thinking: enabled reasoningEffort: max diff --git a/examples/jsonrpc-agent/persistent-tools.cordis.yml b/examples/jsonrpc-agent/persistent-tools.cordis.yml index b5ae81b100..6f42441ca7 100644 --- a/examples/jsonrpc-agent/persistent-tools.cordis.yml +++ b/examples/jsonrpc-agent/persistent-tools.cordis.yml @@ -8,8 +8,6 @@ - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_BASE_URL - id: sandbox name: '@deepseek-ai/dsh-sandbox-local' diff --git a/package.json b/package.json index c1013c23c6..3abd24ca1a 100644 --- a/package.json +++ b/package.json @@ -17,101 +17,102 @@ "build": "npm run build:lib && npm run build:web", "build:lib": "tsc -b && tsdown", "build:web": "pnpm --filter @deepseek-ai/dsh-frontend run build", - "clean": "tsx scripts/clean.ts", "change-scope": "tsx scripts/change-scope.ts", - "typecheck": "tsc -b", - "lint": "tsx scripts/run-oxlint.ts .", - "lint:fix": "eslint --config eslint.format.config.mjs --fix . && tsx scripts/run-oxlint.ts . --fix", - "duplication": "jscpd --config .jscpd.json packages scripts", - "test": "vitest run", - "test:coverage": "vitest run --coverage", - "test:e2e": "vitest run --config vitest.e2e.config.ts", - "test:snapshot": "vitest run --config vitest.snapshot.config.ts", - "test:snapshot:record": "DSH_SNAPSHOT=record vitest run --config vitest.snapshot.config.ts --update", - "test:snapshot:refresh": "DSH_SNAPSHOT=refresh vitest run --config vitest.snapshot.config.ts", - "migrate:packed-session-fixtures": "tsx scripts/migrate-packed-session-fixtures.ts", - "test:web": "npm run build && npm run test:web:built", - "test:web:refresh": "npm run build && DSH_SNAPSHOT=refresh vitest run --config vitest.web.config.ts", - "test:web:built": "vitest run --config vitest.web.config.ts", - "test:gui": "vitest run packages/client packages/host", "check:all": "tsx scripts/run-gates.ts check-all", "check:ci": "tsx scripts/run-gates.ts ci-primary", - "check:ci:linux-primary": "tsx scripts/run-gates.ts ci-linux-primary", - "check:ci:static": "tsx scripts/run-gates.ts ci-static", - "check:ci:lint": "tsx scripts/run-gates.ts ci-lint", - "check:ci:coverage": "tsx scripts/run-gates.ts ci-coverage", - "check:ci:snapshot": "tsx scripts/run-gates.ts ci-snapshot", "check:ci:artifacts": "tsx scripts/run-gates.ts ci-artifacts", "check:ci:consumers": "tsx scripts/run-gates.ts ci-consumers", + "check:ci:coverage": "tsx scripts/run-gates.ts ci-coverage", + "check:ci:lint": "tsx scripts/run-gates.ts ci-lint", + "check:ci:linux-primary": "tsx scripts/run-gates.ts ci-linux-primary", + "check:ci:snapshot": "tsx scripts/run-gates.ts ci-snapshot", + "check:ci:static": "tsx scripts/run-gates.ts ci-static", "check:ci:windows-blocking": "tsx scripts/run-gates.ts ci-windows-blocking", "check:ci:windows-complete": "tsx scripts/run-gates.ts ci-windows-complete", "check:ci:windows-observational": "tsx scripts/run-gates.ts ci-windows-observational", - "check:windows-wine": "bash scripts/wine-windows-gates.sh", "check:node-compat": "tsx scripts/run-gates.ts node-compat", - "knip": "knip --treat-config-hints-as-errors", - "publint": "tsx scripts/publint-all.ts", + "check:windows-wine": "bash scripts/wine-windows-gates.sh", + "clean": "tsx scripts/clean.ts", + "constraints": "tsx scripts/check-workspace-constraints.ts", + "demo:acp": "node --import tsx packages/examples/acp-demo/src/bin.ts --config examples/acp-agent/cordis.yml", + "demo:code-mode": "node scripts/demo-code-mode.mjs", + "demo:cordis": "node scripts/demo-cordis.mjs", + "demo:headless": "node --import tsx packages/examples/cli-demo/src/bin.ts --config examples/headless-agent/cordis.yml", + "demo:tui": "node --import tsx/esm apps/cli/src/bin.ts", + "demo:web": "npm run build && node --import tsx/esm apps/cli/src/bin.ts web", + "dev:web": "tsx scripts/dev-web.ts --poll", + "doc-sync": "tsx scripts/run-gates.ts doc-sync", "doc-typecheck": "tsx scripts/doc-typecheck.ts", - "verify-md-wrap": "tsx scripts/verify-md-wrap.ts", - "verify-md-links": "tsx scripts/verify-md-links.ts", - "verify-doc-refs": "tsx scripts/verify-doc-refs.ts", - "verify-package-paths": "tsx scripts/verify-package-paths.ts", - "verify-package-invariants": "tsx scripts/verify-package-invariants.ts", - "verify-built-package-invariants": "node scripts/verify-built-package-invariants.mjs", - "verify-package-readme-model-experience": "tsx scripts/verify-package-readme-model-experience.ts", - "verify-mermaid": "tsx scripts/verify-mermaid.ts", + "docs:build": "pnpm --filter @deepseek-ai/website run build", + "docs:build:mpa": "pnpm --filter @deepseek-ai/website exec vitepress build . --mpa", + "docs:check": "pnpm exec vitest run scripts/project-doc-site.spec.ts && pnpm run docs:build", + "docs:dev": "pnpm --filter @deepseek-ai/website run dev", + "docs:preview": "pnpm --filter @deepseek-ai/website run preview", + "dsh": "node --import tsx/esm apps/cli/src/bin.ts", + "duplication": "jscpd --config .jscpd.json packages scripts", + "gen-config-catalog": "tsx scripts/gen-config-catalog.ts", + "gen-cordis-api": "tsx scripts/gen-cordis-api.ts", + "gen-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts", + "gen-doc-graphs": "tsx scripts/gen-doc-graphs.ts", + "gen-module-graph": "tsx scripts/gen-module-graph.ts", + "gen-persistence-catalog": "tsx scripts/gen-persistence-catalog.ts", + "gen-scoped-events": "tsx scripts/gen-scoped-events.ts", + "gen-third-party-notices": "tsx scripts/gen-third-party-notices.ts", + "gen-tool-catalog": "tsx scripts/gen-tool-catalog.ts", + "gen-translation-brief": "tsx scripts/gen-translation-brief.ts", + "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-package-invariants && pnpm run verify-built-package-invariants && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-runtime-closure && pnpm run verify-vendored-links", + "knip": "knip --treat-config-hints-as-errors", + "lint": "tsx scripts/run-oxlint.ts .", + "lint:fix": "eslint --config eslint.format.config.mjs --fix . && tsx scripts/run-oxlint.ts . --fix", + "migrate:packed-session-fixtures": "tsx scripts/migrate-packed-session-fixtures.ts", + "mock:llm": "node --import tsx packages/support/llm-mock-server/src/bin.ts", + "postinstall": "node scripts/install-lefthook.mjs", + "publint": "tsx scripts/publint-all.ts", + "test": "vitest run", + "test:coverage": "vitest run --coverage", + "test:e2e": "vitest run --config vitest.e2e.config.ts", + "test:gui": "vitest run packages/client packages/host", + "test:snapshot": "vitest run --config vitest.snapshot.config.ts", + "test:snapshot:record": "DSH_SNAPSHOT=record vitest run --config vitest.snapshot.config.ts --update", + "test:snapshot:refresh": "DSH_SNAPSHOT=refresh vitest run --config vitest.snapshot.config.ts", + "test:web": "npm run build && npm run test:web:built", + "test:web:built": "vitest run --config vitest.web.config.ts", + "test:web:refresh": "npm run build && DSH_SNAPSHOT=refresh vitest run --config vitest.web.config.ts", + "typecheck": "tsc -b", "verify-agent-note-classification": "tsx scripts/verify-agent-note-classification.ts", "verify-agent-note-format": "tsx scripts/verify-agent-note-format.ts", "verify-archived-agent-notes": "tsx scripts/verify-archived-agent-notes.ts", - "verify-type-equiv": "tsx scripts/verify-type-equiv.ts", - "verify-translation-prompt": "tsx scripts/verify-translation-prompt.ts", - "verify-translation-pairing": "tsx scripts/verify-translation-pairing.ts", - "gen-translation-brief": "tsx scripts/gen-translation-brief.ts", - "verify-doc-budgets": "tsx scripts/verify-doc-budgets.ts", - "docs:dev": "pnpm --filter @deepseek-ai/website run dev", - "docs:build": "pnpm --filter @deepseek-ai/website run build", - "docs:build:mpa": "pnpm --filter @deepseek-ai/website exec vitepress build . --mpa", - "docs:preview": "pnpm --filter @deepseek-ai/website run preview", - "docs:check": "pnpm exec vitest run scripts/project-doc-site.spec.ts && pnpm run docs:build", - "website:dev": "pnpm run docs:dev", - "website:build": "pnpm run docs:build", - "verify-package-readme-limitations": "tsx scripts/verify-package-readme-limitations.ts", - "verify-node-next-types": "tsx scripts/verify-node-next-types.ts", - "verify-runtime-closure": "tsx scripts/verify-runtime-closure.ts", - "verify-vendored-links": "tsx scripts/verify-vendored-links.ts", - "verify-cordis-config": "tsx scripts/verify-cordis-config.ts", + "verify-built-package-invariants": "node scripts/verify-built-package-invariants.mjs", "verify-client-domain-graph": "tsx scripts/verify-client-domain-graph.ts", - "gen-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts", - "verify-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts --check", - "gen-cordis-api": "tsx scripts/gen-cordis-api.ts", - "verify-cordis-api": "tsx scripts/gen-cordis-api.ts --check", - "verify-export-jsdoc": "tsx scripts/verify-export-jsdoc.ts", - "gen-tool-catalog": "tsx scripts/gen-tool-catalog.ts", - "verify-tool-catalog": "tsx scripts/gen-tool-catalog.ts --check", - "gen-config-catalog": "tsx scripts/gen-config-catalog.ts", "verify-config-catalog": "tsx scripts/gen-config-catalog.ts --check", - "gen-doc-graphs": "tsx scripts/gen-doc-graphs.ts", + "verify-config-source-ownership": "tsx scripts/verify-config-source-ownership.ts", + "verify-cordis-api": "tsx scripts/gen-cordis-api.ts --check", + "verify-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts --check", + "verify-cordis-config": "tsx scripts/verify-cordis-config.ts", + "verify-doc-budgets": "tsx scripts/verify-doc-budgets.ts", "verify-doc-graphs": "tsx scripts/gen-doc-graphs.ts --check", - "gen-persistence-catalog": "tsx scripts/gen-persistence-catalog.ts", - "verify-persistence-catalog": "tsx scripts/gen-persistence-catalog.ts --check", - "gen-third-party-notices": "tsx scripts/gen-third-party-notices.ts", - "verify-third-party-notices": "tsx scripts/gen-third-party-notices.ts --check", - "gen-module-graph": "tsx scripts/gen-module-graph.ts", - "gen-scoped-events": "tsx scripts/gen-scoped-events.ts", - "verify-scoped-events": "tsx scripts/gen-scoped-events.ts --check", + "verify-doc-refs": "tsx scripts/verify-doc-refs.ts", + "verify-export-jsdoc": "tsx scripts/verify-export-jsdoc.ts", + "verify-md-links": "tsx scripts/verify-md-links.ts", + "verify-md-wrap": "tsx scripts/verify-md-wrap.ts", + "verify-mermaid": "tsx scripts/verify-mermaid.ts", "verify-module-graph": "tsx scripts/gen-module-graph.ts --check", - "constraints": "tsx scripts/check-workspace-constraints.ts", - "doc-sync": "tsx scripts/run-gates.ts doc-sync", - "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-package-invariants && pnpm run verify-built-package-invariants && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-runtime-closure && pnpm run verify-vendored-links", - "dsh": "node --import tsx/esm apps/cli/src/bin.ts", - "demo:headless": "node --import tsx packages/examples/cli-demo/src/bin.ts --config examples/headless-agent/cordis.yml", - "demo:tui": "node --import tsx/esm apps/cli/src/bin.ts", - "demo:code-mode": "node scripts/demo-code-mode.mjs", - "demo:cordis": "node scripts/demo-cordis.mjs", - "demo:acp": "node --import tsx packages/examples/acp-demo/src/bin.ts --config examples/acp-agent/cordis.yml", - "demo:web": "npm run build && node --import tsx/esm apps/cli/src/bin.ts web", - "mock:llm": "node --import tsx packages/support/llm-mock-server/src/bin.ts", - "dev:web": "tsx scripts/dev-web.ts --poll", - "postinstall": "node scripts/install-lefthook.mjs" + "verify-node-next-types": "tsx scripts/verify-node-next-types.ts", + "verify-package-invariants": "tsx scripts/verify-package-invariants.ts", + "verify-package-paths": "tsx scripts/verify-package-paths.ts", + "verify-package-readme-limitations": "tsx scripts/verify-package-readme-limitations.ts", + "verify-package-readme-model-experience": "tsx scripts/verify-package-readme-model-experience.ts", + "verify-persistence-catalog": "tsx scripts/gen-persistence-catalog.ts --check", + "verify-runtime-closure": "tsx scripts/verify-runtime-closure.ts", + "verify-scoped-events": "tsx scripts/gen-scoped-events.ts --check", + "verify-third-party-notices": "tsx scripts/gen-third-party-notices.ts --check", + "verify-tool-catalog": "tsx scripts/gen-tool-catalog.ts --check", + "verify-translation-pairing": "tsx scripts/verify-translation-pairing.ts", + "verify-translation-prompt": "tsx scripts/verify-translation-prompt.ts", + "verify-type-equiv": "tsx scripts/verify-type-equiv.ts", + "verify-vendored-links": "tsx scripts/verify-vendored-links.ts", + "website:build": "pnpm run docs:build", + "website:dev": "pnpm run docs:dev" }, "devDependencies": { "@agentclientprotocol/sdk": "0.25.1", diff --git a/packages/credentials/credentials-local/package.json b/packages/credentials/credentials-local/package.json index 644904676a..132db124b2 100644 --- a/packages/credentials/credentials-local/package.json +++ b/packages/credentials/credentials-local/package.json @@ -29,6 +29,7 @@ "peerDependencies": { "@deepseek-ai/dsh-atomic-write": "^0.0.1", "@deepseek-ai/dsh-credentials": "^0.0.1", + "@deepseek-ai/dsh-environment": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-paths": "^0.0.1", "cordis": "^4.0.0-rc.7" @@ -41,6 +42,7 @@ "devDependencies": { "@deepseek-ai/dsh-atomic-write": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", + "@deepseek-ai/dsh-environment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", "cordis": "^4.0.0-rc.7" diff --git a/packages/credentials/credentials-local/src/index.ts b/packages/credentials/credentials-local/src/index.ts index bc1214d11b..6d5db0776f 100644 --- a/packages/credentials/credentials-local/src/index.ts +++ b/packages/credentials/credentials-local/src/index.ts @@ -1,12 +1,29 @@ /** - * File-backed credentials provider layering the live process environment over - * a `$DSH_HOME/.credentials.yaml` 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 patching only its own key — comments and - * the formatting of every untouched entry survive — external edits - * hot-publish through the seam, and each reload replaces the snapshot + * File-backed credentials provider over `$DSH_HOME/.credentials.yaml`, layered + * against the environment by how much each layer is trusted: + * + * ```text + * inherited process environment (read-only, wins) + * > $DSH_HOME/.credentials.yaml (provider-managed, writable) + * > $DSH_HOME/.env (read-only fallback) + * ``` + * + * The inherited environment wins because `DEEPSEEK_API_KEY=… dsh`, a CI + * secret, or a container `-e` is this run's explicit intent; it cannot be + * edited from inside, so it must be *visibly* read-only rather than silently + * shadow writes. Everything below it loses to the managed store, so a key the + * web page or TUI writes takes effect immediately even when an older key sits + * in the user's `.env`. + * + * The invoking directory's `.env` supplies no credential at all. A project + * directory can be written by the model, and a substituted key would send + * every request — prompts included — through an account someone else reads; + * that decision belongs to the launching shell, not to a discovered file. + * + * The file is the provider-managed writable source: every write re-reads the + * document under a cross-process writer lock before patching only its own key + * — comments and the formatting of every untouched entry survive — external + * edits hot-publish through the seam, and each reload replaces the snapshot * wholesale so a deleted entry never lingers in memory. * * The document holds nothing but credentials, which is why it is a strict @@ -25,8 +42,10 @@ import { dirname, 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 { environmentOf } from '@deepseek-ai/dsh-environment' import { Credentials, credentialRef } from '@deepseek-ai/dsh-credentials' import type { CredentialInfo, CredentialRef, ResolvedCredential } from '@deepseek-ai/dsh-credentials' +import type { EnvironmentEntry } from '@deepseek-ai/dsh-environment' /** Basename of the credentials document inside the harness home. */ export const CREDENTIALS_FILENAME = '.credentials.yaml' @@ -169,6 +188,18 @@ export class CredentialsLocal extends Credentials { this.spec = resolveSpec(config) } + /** The inherited-environment value for a reference, or `undefined` when empty or unset. */ + private inherited(ref: CredentialRef): string | undefined { + const entry = environmentOf(this.ctx).getFrom(ref, ['process']) + return entry !== undefined && entry.value.length > 0 ? entry.value : undefined + } + + /** The user `.env` fallback for a reference — below the managed store, never above it. */ + private userEnvFallback(ref: CredentialRef): EnvironmentEntry | undefined { + const entry = environmentOf(this.ctx).getFrom(ref, ['user-env']) + return entry !== undefined && entry.value.length > 0 ? entry : undefined + } + async* [Service.init](): AsyncGenerator<() => Promise | void, void, void> { yield async () => { // Drain: refuse new operations, then settle the queued ones so disposal @@ -214,20 +245,27 @@ export class CredentialsLocal extends Credentials { } override resolve(ref: CredentialRef): Promise { - const env = process.env[ref] - if (env !== undefined && env.length > 0) return Promise.resolve({ value: env, source: 'env' }) + const inherited = this.inherited(ref) + if (inherited !== undefined) return Promise.resolve({ value: inherited, source: 'env' }) const stored = this.values.get(ref) if (stored !== undefined) return Promise.resolve({ value: stored, source: 'file' }) + const fallback = this.userEnvFallback(ref) + if (fallback !== undefined) return Promise.resolve({ value: fallback.value, source: 'user-env' }) return Promise.resolve(undefined) } override describe(ref: CredentialRef): Promise { - const env = process.env[ref] - if (env !== undefined && env.length > 0) { + // Only the inherited environment is unwritable: it is the one layer this + // process cannot edit. A user `.env` value is writable in the sense that + // matters — storing a key replaces it as the effective one. + if (this.inherited(ref) !== undefined) { return Promise.resolve({ configured: true, source: 'env', writable: false }) } const stored = this.values.get(ref) if (stored !== undefined) return Promise.resolve({ configured: true, source: 'file', writable: true }) + if (this.userEnvFallback(ref) !== undefined) { + return Promise.resolve({ configured: true, source: 'user-env', writable: true }) + } return Promise.resolve({ configured: false, writable: true }) } @@ -303,13 +341,16 @@ export class CredentialsLocal extends Credentials { }) } - /** Reject a write the live environment would shadow into apparent no-effect. */ + /** + * Reject a write the inherited environment would shadow into apparent + * no-effect. Only that layer can shadow a write: everything else this + * provider resolves ranks below the document being written. + */ private assertUnshadowed(ref: CredentialRef, verb: 'set' | 'unset'): void { - const env = process.env[ref] - if (env !== undefined && env.length > 0) { + if (this.inherited(ref) !== undefined) { throw new Error( - `credentials-local: "${ref}" is supplied read-only by the process environment, so ${verb} would be` - + ' shadowed; unset it in the launching environment (or in a loaded .env) instead', + `credentials-local: "${ref}" is supplied read-only by the launching environment, so ${verb} would be` + + ' shadowed; unset it in the shell you start dsh from instead', ) } } diff --git a/packages/credentials/credentials-local/tests/local.spec.ts b/packages/credentials/credentials-local/tests/local.spec.ts index d5ffddc54d..abc3521111 100644 --- a/packages/credentials/credentials-local/tests/local.spec.ts +++ b/packages/credentials/credentials-local/tests/local.spec.ts @@ -4,6 +4,7 @@ import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { credentialRef } from '@deepseek-ai/dsh-credentials' +import { createEnvironmentSnapshot, DSH_ENVIRONMENT_KEY } from '@deepseek-ai/dsh-environment' import type { CredentialRef } from '@deepseek-ai/dsh-credentials' import { CredentialsLocal, resolveSpec } from '../src/index.ts' @@ -100,6 +101,74 @@ describe('layering and reads', () => { }) }) +describe('layer ladder', () => { + // inherited process env > .credentials.yaml > $DSH_HOME/.env, and the + // invoking directory's .env supplies no credential at all. + async function bootLayered( + path: string, + layers: Parameters[0], + ): Promise { + const ctx = new Context() + ctx.provide(DSH_ENVIRONMENT_KEY, createEnvironmentSnapshot(layers)) + const fiber = ctx.plugin(CredentialsLocal, { path, watch: false }) + cleanups.push(async () => { await fiber.dispose() }) + await fiber + return ctx + } + + it('lets the stored value beat the user .env, so a UI write takes effect immediately', async () => { + const dir = await tempDir() + const path = join(dir, '.credentials.yaml') + await writeFile(path, 'DSH_CRED_TEST: stored\n') + const ctx = await bootLayered(path, [ + { source: 'process', values: {} }, + { source: 'user-env', path: '/home/.dsh/.env', values: { DSH_CRED_TEST: 'older-user-env' } }, + ]) + expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'stored', source: 'file' }) + // The old dead end is gone: a key sitting in the user's .env no longer + // makes the stored one unwritable. + expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'file', writable: true }) + await expect(ctx.credentials.set(KEY, 'rotated')).resolves.toBeUndefined() + expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'rotated', source: 'file' }) + }) + + it('serves the user .env only when nothing is stored', async () => { + const dir = await tempDir() + const ctx = await bootLayered(join(dir, '.credentials.yaml'), [ + { source: 'process', values: {} }, + { source: 'user-env', path: '/home/.dsh/.env', values: { DSH_CRED_TEST: 'from-user-env' } }, + ]) + expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'from-user-env', source: 'user-env' }) + // Writable: storing a key replaces it as the effective one. + expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'user-env', writable: true }) + }) + + it('ignores the invoking directory .env entirely', async () => { + const dir = await tempDir() + const ctx = await bootLayered(join(dir, '.credentials.yaml'), [ + { source: 'process', values: {} }, + { source: 'project-env', path: '/work/.env', values: { DSH_CRED_TEST: 'from-project' } }, + ]) + // A project directory can be written by the model, and a substituted key + // would route every request through an account someone else reads. + expect(await ctx.credentials.resolve(KEY)).toBeUndefined() + expect(await ctx.credentials.describe(KEY)).toEqual({ configured: false, writable: true }) + }) + + it('lets only the inherited environment shadow the store, read-only', async () => { + const dir = await tempDir() + const path = join(dir, '.credentials.yaml') + await writeFile(path, 'DSH_CRED_TEST: stored\n') + const ctx = await bootLayered(path, [ + { source: 'process', values: { DSH_CRED_TEST: 'from-shell' } }, + { source: 'user-env', path: '/home/.dsh/.env', values: { DSH_CRED_TEST: 'from-user-env' } }, + ]) + expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'from-shell', source: 'env' }) + expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'env', writable: false }) + await expect(ctx.credentials.set(KEY, 'next')).rejects.toThrow(/launching environment/) + }) +}) + describe('document validation', () => { // Every rejection below is a boot failure rather than a skipped entry: this // document holds nothing but credentials, so an ignored key would read as diff --git a/packages/credentials/credentials-local/tsconfig.json b/packages/credentials/credentials-local/tsconfig.json index 3acfbdeffe..75e6b0aeb0 100644 --- a/packages/credentials/credentials-local/tsconfig.json +++ b/packages/credentials/credentials-local/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../../util/atomic-write" }, + { + "path": "../../util/environment" + }, { "path": "../../util/paths" }, diff --git a/packages/llm/llm-deepseek/package.json b/packages/llm/llm-deepseek/package.json index 2c39e2d920..f9a114d908 100644 --- a/packages/llm/llm-deepseek/package.json +++ b/packages/llm/llm-deepseek/package.json @@ -28,6 +28,7 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-credentials": "^0.0.1", + "@deepseek-ai/dsh-environment": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-settings": "^0.0.1", @@ -40,6 +41,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-credentials": "workspace:^", + "@deepseek-ai/dsh-environment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index 3ecc0bec77..effa080409 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -16,6 +16,7 @@ import z from 'schemastery' import { LlmError, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm' import type { RetryPolicyConfig } from '@deepseek-ai/dsh-llm' import { credentialRef } from '@deepseek-ai/dsh-credentials' +import { environmentOf, type EnvironmentSnapshot } from '@deepseek-ai/dsh-environment' import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { @@ -62,7 +63,7 @@ export interface Config { apiKey?: string /** Credential reference (environment-variable name) resolved per request; defaults to `DEEPSEEK_API_KEY`. */ apiKeyEnv?: string - /** Endpoint base; falls back to $DEEPSEEK_BASE_URL, then the public API. */ + /** Endpoint base; falls back to $DEEPSEEK_BASE_URL from a trusted environment layer, then the public API. */ baseURL?: string /** Deployment thinking policy; `disabled` limits every conversation request to `off`. */ thinking?: 'enabled' | 'disabled' @@ -103,6 +104,9 @@ 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' +/** Environment variable naming this provider's endpoint, honored only from trusted layers. */ +const BASE_URL_ENV = 'DEEPSEEK_BASE_URL' + /** * One resolution's complete request facts. Connection and credential facts * are one value on purpose: a snapshot the resolver rejects keeps the whole @@ -142,9 +146,13 @@ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): Dee * every default and bound is re-judged here — for the composition entry at * load (fail loud) and for each settings snapshot at its first use. * @param config - raw plugin config or resolved settings snapshot. + * @param environment - this run's environment layers, or `undefined` outside + * the product CLI. Only the launching shell and the user's own `.env` may + * supply an endpoint: a base URL decides where the resolved API key is sent, + * so a file inside the workspace must not be able to redirect it. * @returns validated connection facts plus the credential reference. */ -export function resolveAdapterOptions(config: Config): ResolvedDeepSeekOptions { +export function resolveAdapterOptions(config: Config, environment?: EnvironmentSnapshot): ResolvedDeepSeekOptions { if (config.thinking === 'disabled' && config.reasoningEffort !== undefined && config.reasoningEffort !== 'off') { @@ -169,7 +177,9 @@ export function resolveAdapterOptions(config: Config): ResolvedDeepSeekOptions { return { ...config.apiKey !== undefined && config.apiKey.length > 0 ? { apiKey: config.apiKey } : {}, apiKeyEnv: credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV), - baseURL: config.baseURL ?? process.env.DEEPSEEK_BASE_URL ?? PUBLIC_BASE_URL, + baseURL: config.baseURL + ?? environment?.getFrom(BASE_URL_ENV, ['process', 'user-env'])?.value + ?? PUBLIC_BASE_URL, defaults: { thinking: config.thinking, reasoningEffort: config.reasoningEffort, @@ -190,7 +200,7 @@ export function apply(ctx: Context, config: Config): void { const raw = current() if (raw === lastRaw && lastGood !== undefined) return lastGood try { - const next = resolveAdapterOptions(raw) + const next = resolveAdapterOptions(raw, environmentOf(ctx)) lastRaw = raw lastGood = next return next @@ -217,10 +227,12 @@ export function apply(ctx: Context, config: Config): void { 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 + // Without the seam there is no managed store to rank against, so the + // launching environment is the whole credential plane — but only that + // layer: a key from a discovered project file would route this request + // through an account the launch never chose. + const inherited = environmentOf(ctx).getFrom(ref, ['process']) + if (inherited !== undefined && inherited.value.length > 0) return inherited.value } throw new LlmError( `llm-deepseek: no API key for provider route "${PROVIDER}"; store ${ref} through the credentials` diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index ec4a271f15..c9db376c8a 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' +import { createEnvironmentSnapshot } from '@deepseek-ai/dsh-environment' import LlmService, { createUserMessage, CONTEXT_WINDOW_EXCEEDED_CODE, errorChain, @@ -12,7 +13,7 @@ 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, resolveAdapterOptions } from '@deepseek-ai/dsh-llm-deepseek' +import { DeepSeekAdapter, PUBLIC_BASE_URL, resolveAdapterOptions } from '@deepseek-ai/dsh-llm-deepseek' import { httpErrorCode } from '../src/adapter.ts' import { assemble } from './assemble.ts' import { closeMockServers, mockServer, textEvents } from './mock-server.ts' @@ -906,6 +907,25 @@ describe('plugin registration and config', () => { expect(server.requests).toHaveLength(1) }) + + it('takes DEEPSEEK_BASE_URL from the launching shell or the user .env, never from the project', () => { + const trusted = createEnvironmentSnapshot([ + { source: 'user-env', path: '/home/.dsh/.env', values: { DEEPSEEK_BASE_URL: 'https://user.example' } }, + ]) + expect(resolveAdapterOptions({}, trusted).baseURL).toBe('https://user.example') + // A base URL decides where the resolved API key is sent, so a file inside + // a model-writable workspace must not be able to redirect it. + const project = createEnvironmentSnapshot([ + { source: 'project-env', path: '/work/.env', values: { DEEPSEEK_BASE_URL: 'https://attacker.example' } }, + ]) + expect(resolveAdapterOptions({}, project).baseURL).toBe(PUBLIC_BASE_URL) + // An explicitly configured endpoint outranks every environment layer, so a + // stale shell value cannot rewrite a deployment's own gateway. + const shell = createEnvironmentSnapshot([ + { source: 'process', values: { DEEPSEEK_BASE_URL: 'https://stale.example' } }, + ]) + expect(resolveAdapterOptions({ baseURL: 'https://gateway.internal' }, shell).baseURL).toBe('https://gateway.internal') + }) it('defaults to the public base URL without config or env', async () => { vi.stubEnv('DEEPSEEK_API_KEY', 'k') vi.stubEnv('DEEPSEEK_BASE_URL', undefined) diff --git a/packages/llm/llm-deepseek/tsconfig.json b/packages/llm/llm-deepseek/tsconfig.json index ee8a81e73b..0b524a257b 100644 --- a/packages/llm/llm-deepseek/tsconfig.json +++ b/packages/llm/llm-deepseek/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../../credentials/credentials" }, + { + "path": "../../util/environment" + }, { "path": "../../settings/settings" }, diff --git a/packages/llm/llm-pi-ai/package.json b/packages/llm/llm-pi-ai/package.json index 43b97a14f0..5e86ac5b40 100644 --- a/packages/llm/llm-pi-ai/package.json +++ b/packages/llm/llm-pi-ai/package.json @@ -28,6 +28,7 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-credentials": "^0.0.1", + "@deepseek-ai/dsh-environment": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-settings": "^0.0.1", @@ -40,6 +41,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-credentials": "workspace:^", + "@deepseek-ai/dsh-environment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index 91cb32a181..862aa2afca 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -29,6 +29,7 @@ */ import type { Context } from 'cordis' +import { environmentOf } from '@deepseek-ai/dsh-environment' import { getBuiltinProviders } from '@earendil-works/pi-ai/providers/all' import { LlmError } from '@deepseek-ai/dsh-llm' import type { AdapterRegistrationHandle } from '@deepseek-ai/dsh-llm' @@ -99,9 +100,9 @@ export function apply(ctx: Context, config: Config): void { 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] + // Without the seam the launching environment is the whole credential + // plane — but only that layer, never a discovered project file. + : environmentOf(ctx).getFrom(ref, ['process'])?.value 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` diff --git a/packages/llm/llm-pi-ai/tsconfig.json b/packages/llm/llm-pi-ai/tsconfig.json index ee8a81e73b..dd364e493a 100644 --- a/packages/llm/llm-pi-ai/tsconfig.json +++ b/packages/llm/llm-pi-ai/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../../util/environment" + }, { "path": "../../../vendor/cosmokit" }, diff --git a/packages/ui/app-boot/package.json b/packages/ui/app-boot/package.json index 18a42a27a1..fc4f173263 100644 --- a/packages/ui/app-boot/package.json +++ b/packages/ui/app-boot/package.json @@ -27,12 +27,14 @@ ], "license": "BSD-3-Clause", "dependencies": { + "dotenv": "^17.2.0", "js-yaml": "^4.2.0" }, "peerDependencies": { "@cordisjs/plugin-hmr": "^1.0.15", "@cordisjs/plugin-include": "^1.0.4", "@cordisjs/plugin-loader": "^1.0.0-rc.5", + "@deepseek-ai/dsh-environment": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-paths": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", @@ -48,6 +50,7 @@ "@cordisjs/plugin-include": "workspace:^", "@cordisjs/plugin-loader": "workspace:^", "@cordisjs/plugin-timer": "workspace:^", + "@deepseek-ai/dsh-environment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index 78dff3eca8..0f3cbd6687 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -9,11 +9,13 @@ import { pathToFileURL } from 'node:url' import { readFileSync } from 'node:fs' import { basename, dirname, resolve } from 'node:path' +import { parse as parseDotenv } from 'dotenv' import * as yaml from 'js-yaml' import { Context, type FiberState } from 'cordis' import Loader, { type Entry, type EntryOptions } from '@cordisjs/plugin-loader' import Include, { applyEntryPatches, entryListSchema, type PatchOptions } from '@cordisjs/plugin-include' import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-paths' +import { createEnvironmentSnapshot, isBootstrapOnly, type EnvironmentSnapshot } from '@deepseek-ai/dsh-environment' import type {} from '@cordisjs/plugin-hmr' // Side-effect type import: resolves `ctx.get('systemPrompt')` to the service. import type {} from '@deepseek-ai/dsh-system-prompt' @@ -66,12 +68,57 @@ export function loadEnv( } /** - * Load the dsh product CLI's user environment: the invoking directory's `.env` + * Parse one directory's `.env` without applying it, rejecting any bootstrap + * variable it declares. A discovered file must not decide how this process + * launches, where its code and model-visible instructions come from, or how it + * reaches the network, so a violation fails the launch BEFORE anything is + * materialized — reporting it afterwards would leave the process already + * running under the value it refused. + * @param binName - the diagnostic prefix on the thrown error. + * @param dir - the directory whose `.env` to read. + * @param warn - sink for the one-line unreadable-file diagnostic. + * @returns the parsed entries, or `undefined` when the file is absent or unreadable. + * @throws when the file declares a name {@link isBootstrapOnly} rejects. + */ +function readEnvLayer( + binName: string, dir: string, warn: (line: string) => void, +): { path: string; values: Record } | undefined { + const path = resolve(dir, '.env') + let content: string + try { + content = readFileSync(path, 'utf8') + } catch (error) { + if ((error as NodeJS.ErrnoException | null)?.code !== 'ENOENT') { + warn(`${binName}: failed to load .env: ${String(error)}\n`) + } + // ENOENT (no .env) is fine — rely on the ambient environment. + return undefined + } + const values = parseDotenv(content) + for (const name of Object.keys(values)) { + if (!isBootstrapOnly(name)) continue + throw new Error( + `${binName}: ${path} sets "${name}", which only the launching environment may set` + + ' (it decides how this process starts, where its code and instructions load from, or how it' + + ` reaches the network); export ${name} instead of putting it in a .env file`, + ) + } + return { path, values } +} + +/** + * Load the dsh product CLI's user environment and return it as a snapshot that + * remembers which layer supplied each value: the invoking directory's `.env` * over the Harness home's `.env`, both under the inherited process - * environment. `process.loadEnvFile` never replaces a name that is already - * set, so loading the project file first and the user file second is what - * makes the layering `user < project < inherited`; the app-boot tests pin all - * three layers because that ordering is the whole contract. + * environment. + * + * Each layer is parsed and checked before anything is applied, then applied in + * the order that makes the layering `user < project < inherited` — + * `process.loadEnvFile` never replaces a name already set. Values do reach + * `process.env`, because a user's own `--config` tree and third-party + * libraries read it; the returned snapshot is the authority for everything the + * harness itself resolves, since `process.env` alone cannot say whether a + * value came from the launching shell or from a file inside the workspace. * * The Harness home is resolved from the inherited environment *before* either * file loads, so a project `.env` can never redirect which user document is @@ -82,17 +129,28 @@ export function loadEnv( * These are ordinary environment values with ordinary environment reach. A * secret the Harness should own and isolate belongs in the credentials * document, which is never materialized here. - * @param binName - the diagnostic prefix on the warn lines. + * @param binName - the diagnostic prefix on the diagnostics. * @param cwd - the invoking directory whose `.env` is the project layer. * @param warn - sink for the one-line misconfiguration diagnostics. + * @returns this run's frozen environment snapshot. + * @throws when either file declares a bootstrap-only variable. */ export function loadLayeredEnv( binName: string, cwd: string = process.cwd(), warn: (line: string) => void = line => void process.stderr.write(line), -): void { +): EnvironmentSnapshot { const home = resolveDshHome() - loadEnv(binName, cwd, warn) - loadEnv(binName, home, warn) + const inherited = { ...process.env } as Record + // Parse both layers first: a rejection must not leave one file applied. + const project = readEnvLayer(binName, cwd, warn) + const user = home === resolve(cwd) ? undefined : readEnvLayer(binName, home, warn) + if (project !== undefined) process.loadEnvFile(project.path) + if (user !== undefined) process.loadEnvFile(user.path) + return createEnvironmentSnapshot([ + { source: 'process', values: inherited }, + ...project === undefined ? [] : [{ source: 'project-env' as const, path: project.path, values: project.values }], + ...user === undefined ? [] : [{ source: 'user-env' as const, path: user.path, values: user.values }], + ]) } /** diff --git a/packages/ui/app-boot/tests/app-boot.spec.ts b/packages/ui/app-boot/tests/app-boot.spec.ts index ece98a9716..fba1ad1993 100644 --- a/packages/ui/app-boot/tests/app-boot.spec.ts +++ b/packages/ui/app-boot/tests/app-boot.spec.ts @@ -87,7 +87,7 @@ describe('loadEnv', () => { }) describe('loadLayeredEnv', () => { - const NAMES = ['DSH_APP_BOOT_LAYERED_SHARED', 'DSH_APP_BOOT_LAYERED_USER', 'DSH_APP_BOOT_LAYERED_PROJECT'] as const + const NAMES = ['APP_BOOT_LAYERED_SHARED', 'APP_BOOT_LAYERED_USER', 'APP_BOOT_LAYERED_PROJECT'] as const function clear(): void { for (const name of NAMES) Reflect.deleteProperty(process.env, name) @@ -99,18 +99,18 @@ describe('loadLayeredEnv', () => { writeFileSync(join(home, '.env'), [ `${NAMES[0]}=user`, `${NAMES[1]}=user-only`, - 'DSH_APP_BOOT_LAYERED_INHERITED=user-loses', + 'APP_BOOT_LAYERED_INHERITED=user-loses', '', ].join('\n')) writeFileSync(join(project, '.env'), [ `${NAMES[0]}=project`, `${NAMES[2]}=project-only`, - 'DSH_APP_BOOT_LAYERED_INHERITED=project-loses', + 'APP_BOOT_LAYERED_INHERITED=project-loses', '', ].join('\n')) clear() vi.stubEnv('DSH_HOME', home) - vi.stubEnv('DSH_APP_BOOT_LAYERED_INHERITED', 'inherited') + vi.stubEnv('APP_BOOT_LAYERED_INHERITED', 'inherited') const warn = vi.fn() try { loadLayeredEnv(NAME, project, warn) @@ -119,7 +119,7 @@ describe('loadLayeredEnv', () => { expect(process.env[NAMES[0]]).toBe('project') expect(process.env[NAMES[1]]).toBe('user-only') expect(process.env[NAMES[2]]).toBe('project-only') - expect(process.env['DSH_APP_BOOT_LAYERED_INHERITED']).toBe('inherited') + expect(process.env['APP_BOOT_LAYERED_INHERITED']).toBe('inherited') expect(warn).not.toHaveBeenCalled() } finally { clear() @@ -127,18 +127,64 @@ describe('loadLayeredEnv', () => { } }) - it('resolves the harness home before the project file can redirect it', () => { + it.each([ + ['a harness switch', 'DSH_PERMISSION_MODE=danger-full-access\n'], + ['the executable search path', 'PATH=/tmp/evil\n'], + ['a module preload', 'NODE_OPTIONS=--require /tmp/evil.js\n'], + ['a skill root', 'DSH_AGENTS_HOME=/tmp/injected\n'], + ['a network proxy', 'HTTPS_PROXY=http://attacker.example\n'], + ['a lowercase network proxy', 'https_proxy=http://attacker.example\n'], + ])('refuses to launch when a .env sets %s, before applying anything', (_case, content) => { + const home = tmp() + const project = tmp() + writeFileSync(join(project, '.env'), `${NAMES[1]}=applied-anyway\n${content}`) + clear() + vi.stubEnv('DSH_HOME', home) + try { + expect(() => loadLayeredEnv(NAME, project, vi.fn())).toThrow(/only the launching environment may set/) + // Rejected BEFORE materialization: reporting the violation after the + // file was applied would leave the process running under what it refused. + expect(process.env[NAMES[1]]).toBeUndefined() + } finally { + clear() + vi.unstubAllEnvs() + } + }) + + it('reports each layer with its absolute path', () => { + const home = tmp() + const project = tmp() + writeFileSync(join(home, '.env'), `${NAMES[1]}=u\n`) + writeFileSync(join(project, '.env'), `${NAMES[2]}=p\n`) + clear() + vi.stubEnv('DSH_HOME', home) + try { + const snapshot = loadLayeredEnv(NAME, project, vi.fn()) + expect(snapshot.layers).toEqual([ + { source: 'process' }, + { source: 'project-env', path: join(project, '.env') }, + { source: 'user-env', path: join(home, '.env') }, + ]) + expect(snapshot.get(NAMES[1])).toEqual({ value: 'u', source: 'user-env', path: join(home, '.env') }) + // getFrom is a refusal, not a demotion: an omitted layer is invisible. + expect(snapshot.getFrom(NAMES[2], ['process', 'user-env'])).toBeUndefined() + } finally { + clear() + vi.unstubAllEnvs() + } + }) + + it('resolves the harness home from the inherited environment, never from a file', () => { const home = tmp() - const decoy = tmp() const project = tmp() writeFileSync(join(home, '.env'), `${NAMES[1]}=real-home\n`) - writeFileSync(join(decoy, '.env'), `${NAMES[1]}=decoy-home\n`) - writeFileSync(join(project, '.env'), `DSH_HOME=${decoy}\n`) + writeFileSync(join(project, '.env'), `${NAMES[2]}=set-by-project\n`) clear() vi.stubEnv('DSH_HOME', home) try { loadLayeredEnv(NAME, project, vi.fn()) expect(process.env[NAMES[1]]).toBe('real-home') + expect(process.env[NAMES[2]]).toBe('set-by-project') } finally { clear() vi.unstubAllEnvs() diff --git a/packages/ui/app-boot/tsconfig.json b/packages/ui/app-boot/tsconfig.json index beb61317dc..18ddbedad3 100644 --- a/packages/ui/app-boot/tsconfig.json +++ b/packages/ui/app-boot/tsconfig.json @@ -26,6 +26,9 @@ { "path": "../../core/system-prompt" }, + { + "path": "../../util/environment" + }, { "path": "../../util/paths" } diff --git a/packages/util/environment/README.i18n.yaml b/packages/util/environment/README.i18n.yaml new file mode 100644 index 0000000000..9d251d1940 --- /dev/null +++ b/packages/util/environment/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/environment/README.md +README.md: f642aa715c87878b2eaab9f034fb18163a6fbd2e +README.zh.md: a095730dbc8c2a4e7dc8dc57dc2930685c8fb453 diff --git a/packages/util/environment/README.md b/packages/util/environment/README.md new file mode 100644 index 0000000000..f642aa715c --- /dev/null +++ b/packages/util/environment/README.md @@ -0,0 +1,42 @@ +# dsh-environment + +English | [中文](README.zh.md) + +This run's environment as one immutable snapshot that remembers **which layer supplied each value**. Consumers resolve user-facing values against it instead of `process.env`, because the layers are not equally trusted and a flattened view cannot tell them apart. + +| Layer | Source id | What it is | +|---|---|---| +| Inherited process environment | `process` | What the launching shell, CI job, or container passed in — this run's explicit intent | +| `/.env` | `project-env` | Whatever the project directory happens to contain; a model working in that workspace can write it | +| `$DSH_HOME/.env` | `user-env` | The user's own machine-level defaults | + +Values do also reach `process.env` — a user's `--config` tree and third-party libraries read it — but that flattened view is not the authority for anything the harness resolves. + +## Resolving + +`get(name)` searches every layer, most trusted first. `getFrom(name, sources)` searches only the layers the caller trusts. + +**Omitting a layer is a refusal, not a demotion.** A base URL decides where a resolved API key is sent, so the LLM adapters ask for `['process', 'user-env']`: no future reordering can let a project file redirect a credential, because that layer is never consulted at all. + +```ts +import type { Context } from 'cordis' +import { environmentOf } from '@deepseek-ai/dsh-environment' + +declare const ctx: Context +const endpoint = environmentOf(ctx).getFrom('DEEPSEEK_BASE_URL', ['process', 'user-env'])?.value +``` + +`environmentOf(ctx)` returns the launcher's snapshot when the product CLI booted the tree, and otherwise the inherited environment as the only layer. That fallback does not weaken the rules: an SDK host or a bare `cordis.yml` discovered no files, so everything it has really is the environment it was launched with. + +## Bootstrap variables + +`isBootstrapOnly(name)` names the variables only the inherited environment may set. The launcher rejects a `.env` that declares one, before applying anything. + +A bootstrap variable decides **how a process launches** (`PATH`, `SHELL`, `NODE_OPTIONS`, `NODE_PATH`, `LD_PRELOAD`, `LD_LIBRARY_PATH`, `DYLD_*`), **where code or model-visible instructions load from** (the whole `DSH_*` namespace, `HOME`, `USERPROFILE`, `XDG_*`), or **how the network is reached and trusted** (`HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, `NO_PROXY`, `SSL_CERT_FILE`, `SSL_CERT_DIR`, `NODE_EXTRA_CA_CERTS`). Matching is case-insensitive, so `https_proxy` is not a bypass. + +The whole `DSH_*` namespace is denied rather than an audited subset: the harness's own switches — the permission mode, the agents home, the bundled skill root — are exactly what a hostile project would want, and a switch added later must not become settable by forgetting to list it. + +## Known Limitations and Deferred Work + +- **The snapshot is not a subprocess boundary** — every layer is also materialized into `process.env`, so ordinary project variables still reach child processes under [`dsh-subprocess`](../../subprocess/subprocess/README.md)'s scrub. Bootstrap variables cannot come from a file at all, but a project `.env` can still set, say, `GIT_SSH_COMMAND` for the tools an agent runs. +- **No per-workspace layer** — the project layer is the *invoking* directory, fixed at launch. A workspace selected later in the Web UI contributes nothing, deliberately: following it would let a model's own workspace change the harness environment mid-session. diff --git a/packages/util/environment/README.zh.md b/packages/util/environment/README.zh.md new file mode 100644 index 0000000000..a095730dbc --- /dev/null +++ b/packages/util/environment/README.zh.md @@ -0,0 +1,42 @@ +# dsh-environment + +[English](README.md) | 中文 + +把本次运行的环境冻结为一份不可变快照,并记住**每个值来自哪一层**。消费方用它而不是 `process.env` 解析面向用户的值,因为各层的可信程度并不相同,而压平后的视图无法区分它们。 + +| 层 | 来源 id | 它是什么 | +|---|---|---| +| 继承的进程环境 | `process` | 启动 shell、CI 任务或容器传入的东西——本次运行的明确意图 | +| `/.env` | `project-env` | 项目目录里恰好有的东西;在该工作区里工作的模型可以写它 | +| `$DSH_HOME/.env` | `user-env` | 用户自己的机器级默认值 | + +这些值同样会进入 `process.env`——用户自己的 `--config` 树和第三方库要读它——但那份压平的视图不是 harness 解析任何值的依据。 + +## 解析 + +`get(name)` 按可信度从高到低搜索所有层。`getFrom(name, sources)` 只搜索调用方信任的层。 + +**省略某一层是拒绝,不是降级。** base URL 决定已解析的 API key 被发往何处,因此 LLM 适配器请求的是 `['process', 'user-env']`:后续任何重新排序都无法让项目文件重定向凭据,因为那一层根本不会被查询。 + +```ts +import type { Context } from 'cordis' +import { environmentOf } from '@deepseek-ai/dsh-environment' + +declare const ctx: Context +const endpoint = environmentOf(ctx).getFrom('DEEPSEEK_BASE_URL', ['process', 'user-env'])?.value +``` + +当产品 CLI(命令行界面)启动了这棵树时,`environmentOf(ctx)` 返回启动器的快照;否则返回只含继承环境的那一层。该回退并不削弱规则:SDK 宿主或裸 `cordis.yml` 从未发现过任何文件,因此它拥有的一切确实就是它被启动时的环境。 + +## bootstrap 变量 + +`isBootstrapOnly(name)` 给出只有继承环境才能设置的变量。启动器一旦发现某个 `.env` 声明了其中之一,就会在应用任何内容之前拒绝启动。 + +bootstrap 变量决定**进程如何启动**(`PATH`、`SHELL`、`NODE_OPTIONS`、`NODE_PATH`、`LD_PRELOAD`、`LD_LIBRARY_PATH`、`DYLD_*`)、**代码或模型可见的指令从哪里加载**(整个 `DSH_*` 命名空间、`HOME`、`USERPROFILE`、`XDG_*`),或者**网络如何抵达与信任**(`HTTP_PROXY`、`HTTPS_PROXY`、`ALL_PROXY`、`NO_PROXY`、`SSL_CERT_FILE`、`SSL_CERT_DIR`、`NODE_EXTRA_CA_CERTS`)。匹配不区分大小写,因此 `https_proxy` 不是绕过手段。 + +整个 `DSH_*` 命名空间被拒绝,而不是只拒绝一份经过审查的子集:harness 自己的开关——权限模式、agents home、内置 skill(技能)根目录——恰恰是敌意项目最想要的,而后来新增的开关不能因为忘记登记就变得可设置。 + +## Known Limitations and Deferred Work + +- **快照不是子进程边界**:每一层同样会被物化进 `process.env`,因此普通的项目变量仍会按 [`dsh-subprocess`](../../subprocess/subprocess/README.md) 的清洗规则抵达子进程。bootstrap 变量完全不能来自文件,但项目 `.env` 仍可以为 agent 运行的工具设置诸如 `GIT_SSH_COMMAND` 之类的变量。 +- **没有按工作区划分的层**:项目层是*调用*目录,在启动时固定。之后在 Web UI 中选择的工作区不贡献任何内容,这是刻意的:跟随它等于让模型自己的工作区在会话中途改变 harness 的环境。 diff --git a/packages/util/environment/package.json b/packages/util/environment/package.json new file mode 100644 index 0000000000..94a2a76ef6 --- /dev/null +++ b/packages/util/environment/package.json @@ -0,0 +1,37 @@ +{ + "name": "@deepseek-ai/dsh-environment", + "description": "Immutable launch-time environment snapshot with per-layer provenance 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-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/environment/src/index.ts b/packages/util/environment/src/index.ts new file mode 100644 index 0000000000..100a0fe9f0 --- /dev/null +++ b/packages/util/environment/src/index.ts @@ -0,0 +1,178 @@ +/** + * The launch-time environment as one immutable snapshot that remembers which + * layer supplied each value. The harness resolves user-facing values against + * this rather than against `process.env`, because the layers differ in how + * much they are trusted: an inherited variable is this run's explicit intent, + * a file discovered under the invoking directory is whatever the project + * happens to contain, and a consumer that cannot tell them apart cannot make + * that distinction. + * + * Values still reach `process.env` as well — a user's own `--config` tree and + * third-party libraries read it — but that flattened view is not the + * authority for anything the harness itself resolves. + * @module @deepseek-ai/dsh-environment + */ + +import type { Context } from 'cordis' + +/** + * Which layer supplied a value, from most to least trusted: the environment + * this process inherited, the invoking directory's `.env`, the Harness home's + * `.env`. + */ +export type EnvironmentSource = 'process' | 'project-env' | 'user-env' + +/** Layer order, most trusted first — the default search order of {@link EnvironmentSnapshot.get}. */ +export const ENVIRONMENT_SOURCES: readonly EnvironmentSource[] = ['process', 'project-env', 'user-env'] + +/** One resolved variable and the layer it came from. */ +export interface EnvironmentEntry { + /** The value as the layer supplied it; may be empty, which each owner judges for itself. */ + value: string + /** The layer that supplied it. */ + source: EnvironmentSource + /** Absolute path of the file that supplied it; absent for `process`. */ + path?: string +} + +/** One environment layer's identity, for diagnostics. */ +export interface EnvironmentLayer { + source: EnvironmentSource + /** Absolute path of the file behind this layer; absent for `process`. */ + path?: string +} + +/** + * The frozen environment of one launch. Construct through + * {@link createEnvironmentSnapshot}; nothing mutates it afterwards, so a + * later `chdir`, workspace switch, or resumed session observes the same + * values a consumer resolved at boot. + */ +export interface EnvironmentSnapshot { + /** + * Resolve one name across every layer, most trusted first. + * @param name - the variable name. + * @returns the winning entry, or `undefined` when no layer supplies it. + */ + get(name: string): EnvironmentEntry | undefined + /** + * Resolve one name across only the layers the caller trusts for this + * decision. Omitting a layer is a refusal, not a demotion: a routing field + * that must never come from a project directory omits `project-env` so no + * ordering change can let it back in. + * @param name - the variable name. + * @param sources - the layers to search, in the caller's own priority order. + * @returns the first matching entry, or `undefined`. + */ + getFrom(name: string, sources: readonly EnvironmentSource[]): EnvironmentEntry | undefined + /** The layers this snapshot was built from, most trusted first. */ + readonly layers: readonly EnvironmentLayer[] +} + +/** One layer's raw contents, as {@link createEnvironmentSnapshot} receives them. */ +export interface EnvironmentLayerInput { + source: EnvironmentSource + /** Absolute path of the file behind this layer; omit for `process`. */ + path?: string + values: Readonly> +} + +/** + * Build the snapshot from each layer's contents. + * @param layers - the layers in any order; the result searches them by {@link ENVIRONMENT_SOURCES}. + * @returns the immutable snapshot. + */ +export function createEnvironmentSnapshot(layers: readonly EnvironmentLayerInput[]): EnvironmentSnapshot { + // Copied per layer so a later mutation of `process.env` — or of a caller's + // own object — cannot change what this snapshot reports. + const bySource = new Map }>() + for (const layer of layers) { + bySource.set(layer.source, { + ...layer.path === undefined ? {} : { path: layer.path }, + values: new Map(Object.entries(layer.values)), + }) + } + const getFrom = (name: string, sources: readonly EnvironmentSource[]): EnvironmentEntry | undefined => { + for (const source of sources) { + const layer = bySource.get(source) + const value = layer?.values.get(name) + if (value === undefined) continue + return { value, source, ...layer?.path === undefined ? {} : { path: layer.path } } + } + return undefined + } + return { + get: name => getFrom(name, ENVIRONMENT_SOURCES), + getFrom, + layers: ENVIRONMENT_SOURCES + .filter(source => bySource.has(source)) + .map((source): EnvironmentLayer => { + const path = bySource.get(source)?.path + return { source, ...path === undefined ? {} : { path } } + }), + } +} + +/** Context slot the launcher fills with this run's snapshot before any config entry mounts. */ +export const DSH_ENVIRONMENT_KEY = 'launcherEnvironment' + +/** + * The snapshot to resolve against, whatever booted this tree: the launcher's + * when the product CLI provided one, otherwise the inherited environment + * alone. + * + * The fallback does not weaken the layer rules — it applies the same rules to + * a host that has exactly one layer. An SDK embedder or a bare `cordis.yml` + * never discovered a project or user file, so everything it has really is the + * environment it was launched with, and `getFrom(..., ['process'])` is exactly + * right for it. + * @param ctx - the consuming plugin's context. + * @returns the snapshot to resolve user-facing values against. + */ +export function environmentOf(ctx: Context): EnvironmentSnapshot { + return ctx.get(DSH_ENVIRONMENT_KEY) + ?? createEnvironmentSnapshot([{ source: 'process', values: process.env as Record }]) +} + +declare module 'cordis' { + interface Context { + /** Launcher-owned snapshot of this run's environment; absent in compositions the product CLI did not boot. */ + launcherEnvironment?: EnvironmentSnapshot + } +} + +/** Exact names no discovered file may set. */ +const BOOTSTRAP_NAMES = new Set([ + // Process launch and module resolution. + 'PATH', 'HOME', 'USERPROFILE', 'SHELL', + 'NODE_OPTIONS', 'NODE_PATH', 'NODE_EXTRA_CA_CERTS', + 'LD_PRELOAD', 'LD_LIBRARY_PATH', + // Network reach and trust. + 'SSL_CERT_FILE', 'SSL_CERT_DIR', + 'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY', +]) + +/** Name prefixes no discovered file may set. */ +const BOOTSTRAP_PREFIXES = ['DSH_', 'XDG_', 'DYLD_'] + +/** + * Whether a variable may come only from the inherited process environment. + * + * A bootstrap variable decides how a process launches (`PATH`, `NODE_OPTIONS`, + * `LD_PRELOAD`), where code or model-visible instructions load from (`DSH_*` + * covers the Harness home, the agents home, and the bundled skill root), or + * how the network is reached and trusted (proxy and CA variables). A file the + * harness merely finds — including one a model can write inside the workspace + * — must never set them, so they are rejected at load rather than ranked + * below another layer. + * + * The whole `DSH_*` namespace is denied rather than an audited subset: the + * harness's own switches are exactly the ones a hostile project would want, + * and a new switch must not become settable by forgetting to list it. + * @param name - the variable name. + * @returns true when only the inherited environment may supply it. + */ +export function isBootstrapOnly(name: string): boolean { + const upper = name.toUpperCase() + return BOOTSTRAP_NAMES.has(upper) || BOOTSTRAP_PREFIXES.some(prefix => upper.startsWith(prefix)) +} diff --git a/packages/util/environment/src/invariant.ts b/packages/util/environment/src/invariant.ts new file mode 100644 index 0000000000..96e53828ae --- /dev/null +++ b/packages/util/environment/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-environment`. + * @module @deepseek-ai/dsh-environment/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-environment' + +/** Cordis companion plugin name. */ +export const name = 'environment-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: the snapshot is frozen before any fiber starts and this package owns no + * event stream or mutable runtime data; its lookup and rejection rules are 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/environment/tests/environment.spec.ts b/packages/util/environment/tests/environment.spec.ts new file mode 100644 index 0000000000..27c7b16e55 --- /dev/null +++ b/packages/util/environment/tests/environment.spec.ts @@ -0,0 +1,118 @@ +import { describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import { + createEnvironmentSnapshot, DSH_ENVIRONMENT_KEY, ENVIRONMENT_SOURCES, environmentOf, isBootstrapOnly, +} from '../src/index.ts' + +const layered = createEnvironmentSnapshot([ + { source: 'process', values: { SHARED: 'from-process', ONLY_PROCESS: 'p' } }, + { source: 'project-env', path: '/work/.env', values: { SHARED: 'from-project', ONLY_PROJECT: 'j' } }, + { source: 'user-env', path: '/home/.dsh/.env', values: { SHARED: 'from-user', ONLY_USER: 'u' } }, +]) + +describe('createEnvironmentSnapshot', () => { + it('resolves across every layer, most trusted first, and reports the winning source', () => { + expect(layered.get('SHARED')).toEqual({ value: 'from-process', source: 'process' }) + expect(layered.get('ONLY_PROJECT')).toEqual({ value: 'j', source: 'project-env', path: '/work/.env' }) + expect(layered.get('ONLY_USER')).toEqual({ value: 'u', source: 'user-env', path: '/home/.dsh/.env' }) + expect(layered.get('ABSENT')).toBeUndefined() + }) + + it('treats an omitted layer as invisible, not merely lower', () => { + // The point of getFrom: a routing field that must never come from a + // project directory cannot be reached by reordering, only by listing it. + expect(layered.getFrom('ONLY_PROJECT', ['process', 'user-env'])).toBeUndefined() + expect(layered.getFrom('SHARED', ['user-env', 'process'])).toEqual({ + value: 'from-user', source: 'user-env', path: '/home/.dsh/.env', + }) + expect(layered.getFrom('SHARED', [])).toBeUndefined() + }) + + it('lists its layers in trust order with their paths', () => { + expect(layered.layers).toEqual([ + { source: 'process' }, + { source: 'project-env', path: '/work/.env' }, + { source: 'user-env', path: '/home/.dsh/.env' }, + ]) + expect(createEnvironmentSnapshot([{ source: 'process', values: {} }]).layers).toEqual([{ source: 'process' }]) + }) + + it('copies each layer, so a later mutation of the source object cannot change it', () => { + const values: Record = { KEY: 'first' } + const snapshot = createEnvironmentSnapshot([{ source: 'process', values }]) + values.KEY = 'second' + values.LATE = 'added' + expect(snapshot.get('KEY')).toEqual({ value: 'first', source: 'process' }) + expect(snapshot.get('LATE')).toBeUndefined() + }) + + it('keeps an empty value as a present value, for its owner to judge', () => { + const snapshot = createEnvironmentSnapshot([{ source: 'process', values: { EMPTY: '' } }]) + expect(snapshot.get('EMPTY')).toEqual({ value: '', source: 'process' }) + }) + + it('orders lookups by ENVIRONMENT_SOURCES regardless of construction order', () => { + const reversed = createEnvironmentSnapshot([ + { source: 'user-env', path: '/u', values: { K: 'u' } }, + { source: 'process', values: { K: 'p' } }, + ]) + expect(ENVIRONMENT_SOURCES).toEqual(['process', 'project-env', 'user-env']) + expect(reversed.get('K')).toEqual({ value: 'p', source: 'process' }) + }) +}) + +describe('environmentOf', () => { + it('returns the launcher snapshot when the product CLI provided one', () => { + const ctx = new Context() + ctx.provide(DSH_ENVIRONMENT_KEY, layered) + expect(environmentOf(ctx)).toBe(layered) + }) + + it('falls back to the inherited environment as the only layer', () => { + vi.stubEnv('DSH_ENV_SPEC_FALLBACK', 'ambient') + try { + const snapshot = environmentOf(new Context()) + expect(snapshot.get('DSH_ENV_SPEC_FALLBACK')).toEqual({ value: 'ambient', source: 'process' }) + // A host that discovered no files has exactly one layer, so the trusted + // lookups every consumer makes still find what it was launched with. + expect(snapshot.getFrom('DSH_ENV_SPEC_FALLBACK', ['process', 'user-env'])?.value).toBe('ambient') + expect(snapshot.layers).toEqual([{ source: 'process' }]) + } finally { + vi.unstubAllEnvs() + } + }) +}) + +describe('isBootstrapOnly', () => { + it.each([ + 'PATH', 'HOME', 'USERPROFILE', 'SHELL', + 'NODE_OPTIONS', 'NODE_PATH', 'NODE_EXTRA_CA_CERTS', + 'LD_PRELOAD', 'LD_LIBRARY_PATH', + 'SSL_CERT_FILE', 'SSL_CERT_DIR', + 'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY', + ])('rejects %s, which decides how the process starts or reaches the network', (name) => { + expect(isBootstrapOnly(name)).toBe(true) + }) + + it.each([ + ['DSH_HOME', 'the harness home'], + ['DSH_PERMISSION_MODE', 'the permission mode'], + ['DSH_AGENTS_HOME', 'a model-visible instruction root'], + ['DSH_ANYTHING_ADDED_LATER', 'a switch that does not exist yet'], + ['XDG_CONFIG_HOME', 'a state root'], + ['DYLD_INSERT_LIBRARIES', 'a library preload'], + ])('rejects the whole namespace: %s (%s)', (name) => { + expect(isBootstrapOnly(name)).toBe(true) + }) + + it('matches case-insensitively, so a lowercase proxy name is not a bypass', () => { + expect(isBootstrapOnly('https_proxy')).toBe(true) + expect(isBootstrapOnly('dsh_permission_mode')).toBe(true) + }) + + it('allows ordinary variables, including provider credentials and endpoints', () => { + for (const name of ['DEEPSEEK_API_KEY', 'DEEPSEEK_BASE_URL', 'EXA_API_KEY', 'MY_PROJECT_FLAG', 'PATHS']) { + expect(isBootstrapOnly(name)).toBe(false) + } + }) +}) diff --git a/packages/util/environment/tsconfig.json b/packages/util/environment/tsconfig.json new file mode 100644 index 0000000000..d970a00263 --- /dev/null +++ b/packages/util/environment/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/packages/web/web-search-deepseek/package.json b/packages/web/web-search-deepseek/package.json index e1dbf720f6..b286c5d421 100644 --- a/packages/web/web-search-deepseek/package.json +++ b/packages/web/web-search-deepseek/package.json @@ -29,6 +29,7 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-credentials": "^0.0.1", + "@deepseek-ai/dsh-environment": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-web": "^0.0.1", @@ -41,6 +42,7 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-credentials-local": "workspace:^", + "@deepseek-ai/dsh-environment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^", diff --git a/packages/web/web-search-deepseek/src/index.ts b/packages/web/web-search-deepseek/src/index.ts index 8569f0e944..3a7e1f65a9 100644 --- a/packages/web/web-search-deepseek/src/index.ts +++ b/packages/web/web-search-deepseek/src/index.ts @@ -9,6 +9,7 @@ import type { Context } from 'cordis' import z from 'schemastery' import type {} from '@deepseek-ai/dsh-agent' import { credentialRef } from '@deepseek-ai/dsh-credentials' +import { environmentOf } from '@deepseek-ai/dsh-environment' import type {} from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-web' import { @@ -80,8 +81,10 @@ export function apply(ctx: Context, config: Config): void { resolveApiKey: async () => { const credentials = ctx.get('credentials') if (credentials !== undefined) return (await credentials.resolve(apiKeyEnv))?.value - const ambient = process.env[apiKeyEnv] - return ambient !== undefined && ambient.length > 0 ? ambient : undefined + // Without the seam the launching environment is the whole credential + // plane — but only that layer, never a discovered project file. + const inherited = environmentOf(ctx).getFrom(apiKeyEnv, ['process']) + return inherited !== undefined && inherited.value.length > 0 ? inherited.value : undefined }, apiKeyEnv, baseURL: config.baseURL ?? DEEPSEEK_DEFAULT_BASE_URL, diff --git a/packages/web/web-search-deepseek/tsconfig.json b/packages/web/web-search-deepseek/tsconfig.json index 76c411d089..b3d8e2ade6 100644 --- a/packages/web/web-search-deepseek/tsconfig.json +++ b/packages/web/web-search-deepseek/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../../util/environment" + }, { "path": "../../../vendor/cosmokit" }, diff --git a/packages/web/web-search-exa/package.json b/packages/web/web-search-exa/package.json index 7d6b802d2e..b9c2fba351 100644 --- a/packages/web/web-search-exa/package.json +++ b/packages/web/web-search-exa/package.json @@ -27,6 +27,7 @@ ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-environment": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-web": "^0.0.1", "cordis": "^4.0.0-rc.7" @@ -35,6 +36,7 @@ "schemastery": "^3.18.0" }, "devDependencies": { + "@deepseek-ai/dsh-environment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^", "cordis": "^4.0.0-rc.7" diff --git a/packages/web/web-search-exa/src/index.ts b/packages/web/web-search-exa/src/index.ts index 67b2eed574..87a8e6572e 100644 --- a/packages/web/web-search-exa/src/index.ts +++ b/packages/web/web-search-exa/src/index.ts @@ -9,6 +9,7 @@ */ import type { Context } from 'cordis' +import { environmentOf } from '@deepseek-ai/dsh-environment' import z from 'schemastery' import type {} from '@deepseek-ai/dsh-web' import { @@ -58,7 +59,10 @@ export const Config: z = z.object({ /** Register the Exa search provider with `ctx.web`. */ export function apply(ctx: Context, config: Config): void { ctx.web.registerSearchProvider(new ExaSearchProvider({ - apiKey: config.apiKey ?? process.env.EXA_API_KEY ?? '', + // Only the launching shell and the user's own `.env` may name this key: + // a project directory can be written by the model, and a substituted key + // would route every request through an account someone else reads. + apiKey: config.apiKey ?? environmentOf(ctx).getFrom('EXA_API_KEY', ['process', 'user-env'])?.value ?? '', baseURL: config.baseURL ?? EXA_DEFAULT_BASE_URL, searchType: config.searchType ?? EXA_DEFAULT_SEARCH_TYPE, highlightsPerResult: config.highlightsPerResult ?? EXA_DEFAULT_HIGHLIGHTS_PER_RESULT, diff --git a/packages/web/web-search-exa/tsconfig.json b/packages/web/web-search-exa/tsconfig.json index e9610ea5c9..770ee55a04 100644 --- a/packages/web/web-search-exa/tsconfig.json +++ b/packages/web/web-search-exa/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../../util/environment" + }, { "path": "../../../vendor/cosmokit" }, diff --git a/packages/web/web-search-perplexity/package.json b/packages/web/web-search-perplexity/package.json index 9aa7080431..5f64df89ee 100644 --- a/packages/web/web-search-perplexity/package.json +++ b/packages/web/web-search-perplexity/package.json @@ -27,6 +27,7 @@ ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-environment": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-web": "^0.0.1", "cordis": "^4.0.0-rc.7" @@ -35,6 +36,7 @@ "schemastery": "^3.18.0" }, "devDependencies": { + "@deepseek-ai/dsh-environment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^", "cordis": "^4.0.0-rc.7" diff --git a/packages/web/web-search-perplexity/src/index.ts b/packages/web/web-search-perplexity/src/index.ts index d673f575c8..b2b5804a92 100644 --- a/packages/web/web-search-perplexity/src/index.ts +++ b/packages/web/web-search-perplexity/src/index.ts @@ -8,6 +8,7 @@ */ import type { Context } from 'cordis' +import { environmentOf } from '@deepseek-ai/dsh-environment' import z from 'schemastery' import type {} from '@deepseek-ai/dsh-web' import { PerplexitySearchProvider, PERPLEXITY_DEFAULT_BASE_URL, PERPLEXITY_DEFAULT_MAX_TOKENS, PERPLEXITY_DEFAULT_MODEL } from './provider.ts' @@ -52,7 +53,10 @@ export const Config: z = z.object({ /** Register the Perplexity search provider with `ctx.web`. */ export function apply(ctx: Context, config: Config): void { ctx.web.registerSearchProvider(new PerplexitySearchProvider({ - apiKey: config.apiKey ?? process.env.PERPLEXITY_API_KEY ?? '', + // Only the launching shell and the user's own `.env` may name this key: + // a project directory can be written by the model, and a substituted key + // would route every request through an account someone else reads. + apiKey: config.apiKey ?? environmentOf(ctx).getFrom('PERPLEXITY_API_KEY', ['process', 'user-env'])?.value ?? '', baseURL: config.baseURL ?? PERPLEXITY_DEFAULT_BASE_URL, model: config.model ?? PERPLEXITY_DEFAULT_MODEL, maxTokens: config.maxTokens ?? PERPLEXITY_DEFAULT_MAX_TOKENS, diff --git a/packages/web/web-search-perplexity/tsconfig.json b/packages/web/web-search-perplexity/tsconfig.json index e9610ea5c9..770ee55a04 100644 --- a/packages/web/web-search-perplexity/tsconfig.json +++ b/packages/web/web-search-perplexity/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../../util/environment" + }, { "path": "../../../vendor/cosmokit" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a74fc2677f..ac412c983a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -243,6 +243,9 @@ importers: '@deepseek-ai/dsh-credentials-local': specifier: workspace:^ version: link:../../packages/credentials/credentials-local + '@deepseek-ai/dsh-environment': + specifier: workspace:^ + version: link:../../packages/util/environment '@deepseek-ai/dsh-frontend': specifier: workspace:^ version: link:../web @@ -2638,6 +2641,9 @@ importers: '@deepseek-ai/dsh-credentials': specifier: workspace:^ version: link:../credentials + '@deepseek-ai/dsh-environment': + specifier: workspace:^ + version: link:../../util/environment '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -3609,6 +3615,9 @@ importers: '@deepseek-ai/dsh-credentials': specifier: workspace:^ version: link:../../credentials/credentials + '@deepseek-ai/dsh-environment': + specifier: workspace:^ + version: link:../../util/environment '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -3637,6 +3646,9 @@ importers: '@deepseek-ai/dsh-credentials': specifier: workspace:^ version: link:../../credentials/credentials + '@deepseek-ai/dsh-environment': + specifier: workspace:^ + version: link:../../util/environment '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -5673,6 +5685,9 @@ importers: packages/ui/app-boot: dependencies: + dotenv: + specifier: ^17.2.0 + version: 17.4.2 js-yaml: specifier: ^4.2.0 version: 4.2.0 @@ -5689,6 +5704,9 @@ importers: '@cordisjs/plugin-timer': specifier: workspace:^ version: link:../../../vendor/timer + '@deepseek-ai/dsh-environment': + specifier: workspace:^ + version: link:../../util/environment '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -5988,6 +6006,15 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis + packages/util/environment: + devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/util/native-command: devDependencies: '@deepseek-ai/dsh-invariants': @@ -6129,6 +6156,9 @@ importers: '@deepseek-ai/dsh-credentials-local': specifier: workspace:^ version: link:../../credentials/credentials-local + '@deepseek-ai/dsh-environment': + specifier: workspace:^ + version: link:../../util/environment '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -6148,6 +6178,9 @@ importers: specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/dsh-environment': + specifier: workspace:^ + version: link:../../util/environment '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -6164,6 +6197,9 @@ importers: specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/dsh-environment': + specifier: workspace:^ + version: link:../../util/environment '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -6420,6 +6456,9 @@ importers: '@deepseek-ai/dsh-credentials': specifier: workspace:^ version: link:../../packages/credentials/credentials + '@deepseek-ai/dsh-environment': + specifier: workspace:^ + version: link:../../packages/util/environment '@deepseek-ai/dsh-fs': specifier: workspace:^ version: link:../../packages/fs/fs @@ -9776,6 +9815,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} @@ -14828,6 +14871,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 a4d555055d..bfbf63ba9a 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -24,6 +24,7 @@ "@deepseek-ai/dsh-compact-basic": "workspace:^", "@deepseek-ai/dsh-compact-tool-result-prune": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", + "@deepseek-ai/dsh-environment": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-fs-policy": "workspace:^", diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index f2173478f1..92c4d31d59 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -569,6 +569,7 @@ function docSyncLeafGates(options: { pnpmScript('markdown-links', 'verify-md-links', { label: 'markdown links' }), pnpmScript('doc-refs', 'verify-doc-refs', { label: 'doc refs' }), pnpmScript('package-paths', 'verify-package-paths', { label: 'package paths' }), + pnpmScript('config-source-ownership', 'verify-config-source-ownership', { label: 'config source ownership' }), pnpmScript('package-readme-model-experience', 'verify-package-readme-model-experience', { label: 'package README model experience' }), pnpmScript('mermaid', 'verify-mermaid'), pnpmScript('agent-note-classification', 'verify-agent-note-classification', { label: 'agent note classification' }), diff --git a/scripts/verify-config-source-ownership.ts b/scripts/verify-config-source-ownership.ts new file mode 100644 index 0000000000..d346b19233 --- /dev/null +++ b/scripts/verify-config-source-ownership.ts @@ -0,0 +1,117 @@ +/** + * Gate: every user-facing value has one owner, and no shipped file smuggles a + * second one in. + * + * Two rules, both about the same failure — a value reaching the harness + * through a path nobody ranked: + * + * 1. Production package source does not read `process.env` directly. A + * credential belongs to `ctx.credentials`, a user-configurable value to the + * environment snapshot plus its owner's resolve step, and a real + * process-launch fact to the app bootstrap. Each remaining read is listed + * below with the reason it is one of those. + * 2. Shipped Cordis configuration does not inline a credential or an endpoint + * from the environment. Doing so re-creates the layer the snapshot exists + * to rank: `apiKey: !!js process.env.X` and `baseURL: !!js process.env.X` + * bypass both the credential seam and the endpoint ladder, and a project + * file could then decide where a key is sent. + * @module scripts/verify-config-source-ownership + */ + +import { globSync, readFileSync } from 'node:fs' +import { resolve, sep } from 'node:path' + +const ROOT = resolve(import.meta.dirname, '..') + +/** + * Production package sources allowed to read `process.env`, each with the + * reason it is a process fact rather than a user-configurable value. Adding a + * row is a deliberate act: state which of the three owners it belongs to and + * why it cannot go there. + */ +const ENV_READ_ALLOWLIST: Readonly> = { + // The environment plane itself. + 'packages/util/environment/src/index.ts': 'defines the snapshot; the inherited environment is its input', + 'packages/ui/app-boot/src/index.ts': 'the app bootstrap that builds the snapshot and reads $DSH_SNAPSHOT', + 'packages/util/paths/src/index.ts': 'resolves $DSH_HOME before any snapshot exists', + + // Process-launch facts owned by the boundary that spawns or is spawned. + 'packages/subprocess/subprocess/src/index.ts': 'scrubs the parent environment for children', + 'packages/workflow/workflow-workerthread/src/host.ts': 'passes the parent environment to a worker thread', + 'packages/ui/tui/src/index.ts': 'reads $COLORTERM, a terminal capability of this process', + 'packages/lsp/lsp-local/src/index.ts': 'passes the parent environment to a language server it spawns', + 'packages/cordis/repository-plugin/src/index.ts': 'resolves an MCP manifest against the spawning environment', + + // Bootstrap-only DSH_* switches, which no discovered file may set. + 'packages/skill/skill-local/src/index.ts': 'reads $DSH_AGENTS_HOME and $DSH_BUNDLED_SKILL_DIR, both bootstrap-only', + 'packages/web/web/src/index.ts': 'reads $DSH_WEB_SEARCH_PROVIDER and $DSH_WEB_FETCH_PROVIDER, both bootstrap-only', + 'packages/host/directory-picker-auto/src/index.ts': 'reads launch facts (display, SSH) of this process', + 'packages/host/directory-picker-auto/src/resolve.ts': 'reads launch facts (display, SSH) of this process', + + // Telemetry identity and consent, resolved once per process at bootstrap. + 'packages/telemetry/session-telemetry-otel/src/user-id.ts': 'derives a machine identity from process facts', + 'packages/sdk/telemetry/src/consent-resolver.ts': 'reads the SDK bootstrap consent switch', + 'packages/sdk/telemetry/src/anonymous-id.ts': 'derives a machine identity from process facts', + + // SDK and example bins: their own app bootstrap, outside the product CLI. + 'packages/sdk/sdk-client/src/client.ts': 'SDK host bootstrap', + 'packages/sdk/helper/src/features/builtin/provider.ts': 'SDK scaffolding reads the developer environment', + 'packages/sdk/helper/src/features/builtin/app.ts': 'SDK scaffolding reads the developer environment', + 'packages/sdk/helper/src/package-managers/package-manager.ts': 'detects the invoking package manager', + 'packages/sdk/create-sdk/src/create-wizard.ts': 'SDK scaffolding reads the developer environment', + 'packages/examples/jsonrpc-demo/src/bin.ts': 'demo bin bootstrap', + 'packages/examples/acp-demo/src/bin.ts': 'demo bin bootstrap', + + // Test and replay infrastructure. + 'packages/support/loader-smoke/src/index.ts': 'test launcher composing a child environment', + 'packages/support/llm-replay/src/index.ts': 'replay fixture switch', + 'packages/support/acp-snapshot/src/launcher.ts': 'snapshot launcher composing a child environment', + + // Browser bundle: `process.env` is replaced at build time, never read at runtime. + 'packages/client/runtime/src/client/contract/store.ts': 'build-time constant folded by the bundler', +} + +/** Shipped Cordis configuration these rules apply to. */ +const SHIPPED_CONFIG_GLOBS = ['apps/*/config/*.yml', 'examples/*/*.cordis.yml', 'examples/*/cordis.yml'] + +/** Config keys that must never be inlined from the environment. */ +const INLINE_DENY = /^\s*(apiKey|baseURL|apiKeyEnv|authToken|headers)\s*:\s*!!js\b/ + +const failures: string[] = [] + +for (const file of globSync('packages/*/*/src/**/*.ts', { cwd: ROOT })) { + const rel = file.split(sep).join('/') + if (!readFileSync(resolve(ROOT, rel), 'utf8').includes('process.env')) continue + if (rel in ENV_READ_ALLOWLIST) continue + failures.push( + `${rel}: reads process.env directly. A credential belongs to ctx.credentials, a user-configurable` + + ' value to environmentOf(ctx) plus its owner\'s resolve step, and a process-launch fact to the app' + + ' bootstrap. If it is genuinely one of those, add it to ENV_READ_ALLOWLIST with the reason.', + ) +} + +for (const glob of SHIPPED_CONFIG_GLOBS) { + for (const file of globSync(glob, { cwd: ROOT })) { + const rel = file.split(sep).join('/') + readFileSync(resolve(ROOT, rel), 'utf8').split('\n').forEach((line, index) => { + if (!INLINE_DENY.test(line)) return + failures.push( + `${rel}:${String(index + 1)}: inlines a credential or endpoint from the environment.` + + ' The adapter resolves apiKeyEnv through ctx.credentials and the endpoint through the' + + ' environment snapshot; inlining here bypasses both ladders.', + ) + }) + } +} + +if (failures.length > 0) { + process.stderr.write('verify-config-source-ownership: configuration source ownership violated:\n') + for (const failure of failures) process.stderr.write(` ${failure}\n`) + process.exit(1) +} + +const allowed = Object.keys(ENV_READ_ALLOWLIST).length +process.stdout.write( + `verify-config-source-ownership: no unregistered process.env reads (${String(allowed)} allowlisted)` + + ' and no credential or endpoint inlined in shipped configuration.\n', +) diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 4104ff8fdc..641daefa01 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -33,6 +33,7 @@ const NO_MODEL_EXPERIENCE_SECTION: Readonly> = { 'packages/core/scope': 'The package is a model-agnostic registration and lifecycle primitive; model-facing consumers own any context selection.', 'packages/util/brand': 'The package is a type-only primitive erased at compile time.', 'packages/util/paths': 'The package only resolves harness-owned host paths; model-facing consumers own any rendered use.', + 'packages/util/environment': 'The package only resolves host environment values; model-facing consumers own any rendered use.', } /** diff --git a/tsconfig.host.json b/tsconfig.host.json index 82abd3cfc3..0e3aa509fa 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -72,6 +72,7 @@ { "path": "./vendor/hmr" }, { "path": "./vendor/logger-console" }, { "path": "./packages/util/brand" }, + { "path": "./packages/util/environment" }, { "path": "./packages/util/native-command" }, { "path": "./packages/util/paths" }, { "path": "./packages/util/timeout" }, From 8c2970e70ef7aa3bcf923648e5bb06447efd74b8 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 4 Aug 2026 17:16:11 +0800 Subject: [PATCH 05/27] fix(config): trust the invoking project, and stop leaking what it must not decide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found five real defects in the configuration-source work, all confirmed against the code rather than argued: 1. The note claimed --config outranks settings.yaml. It does not: the settings seam registers a plugin's cordis entry config as the `base` layer and the user section layers over it, and the seam cannot tell a shipped value from a --config one. The note now states shipped reality and names --config-replace as the lever for a deployment that must win. Separately, a literal `apiKey` in settings outranked both the environment and .credentials.yaml — the field is removed, so configuration carries a reference and nothing else. 2. DEEPSEEK_SEARCH_BASE_URL was functionally deleted: the shipped inline went away without the provider learning to read it. It now resolves from the environment snapshot, as the README always claimed. 3. The bootstrap deny list missed the interpreter start-up hooks. BASH_ENV is the sharpest: `bash -c` sources it on every bash tool call, so a project .env could run a file of its choosing before every command. The list now covers BASH_ENV and its per-language siblings, the Git hook commands, and the remaining preload and CA variables, organised by what a variable does rather than which runtime owns it. 4. YAML parse errors quoted the offending source line — which in a credentials document is the secret — into boot stderr and the watcher's logger. Only the error code and position are reported now, in credentials-local and settings-local alike, pinned by a test that asserts the secret is absent. 5. 0600 governed only files the harness wrote. A hand-created 0644 document was read normally. POSIX now checks the mode before reading contents, at boot and on every reload; Windows has no mode to inspect and is skipped rather than faked. The project a session is launched in is trusted by default, with no prompt and no stored trust record: it may supply its own endpoint, ordinary variables, and a key ranked below the managed store. Trust stops at the harness itself — a discovered file still cannot set DSH_PERMISSION_MODE, PATH, BASH_ENV, or the rest, because those take effect with no user action, before any turn, outside the permission policy and the sandbox. --- ...4-configuration-source-ownership.i18n.yaml | 4 +- ...26-08-04-configuration-source-ownership.md | 35 ++++--- ...08-04-configuration-source-ownership.zh.md | 37 +++++--- docs/config-catalog.md | 4 +- .../fixtures/deepseek-defaults.cordis.yml | 1 - .../headless-agent/tests/headless.snapshot.ts | 10 +- .../stream-json.expected.jsonl | 4 +- .../credentials-local/src/index.ts | 94 +++++++++++++++---- .../credentials-local/tests/local.spec.ts | 93 +++++++++++++----- .../tests/review-fixes.spec.ts | 9 +- .../credentials-local/tests/watcher.spec.ts | 29 +++--- packages/llm/llm-deepseek/src/adapter.ts | 9 +- packages/llm/llm-deepseek/src/index.ts | 24 ++--- .../llm/llm-deepseek/tests/adapter.spec.ts | 41 +++----- .../llm-deepseek/tests/dynamic-config.spec.ts | 26 ++--- .../tests/loader-composition.spec.ts | 11 ++- packages/llm/llm-pi-ai/src/index.ts | 5 +- .../llm-pi-ai/tests/dynamic-config.spec.ts | 4 +- .../tests/loader-composition.spec.ts | 2 +- .../tests/transport-recovery.spec.ts | 4 +- packages/settings/settings-local/src/index.ts | 8 +- packages/util/environment/README.i18n.yaml | 4 +- packages/util/environment/README.md | 12 ++- packages/util/environment/README.zh.md | 12 ++- packages/util/environment/src/index.ts | 46 ++++++--- .../web/web-search-deepseek/README.i18n.yaml | 4 +- packages/web/web-search-deepseek/README.md | 4 +- packages/web/web-search-deepseek/README.zh.md | 4 +- packages/web/web-search-deepseek/src/index.ts | 19 +++- packages/web/web-search-exa/src/index.ts | 7 +- .../web/web-search-perplexity/src/index.ts | 7 +- 31 files changed, 366 insertions(+), 207 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml index 7ff8cfa74c..0bc04dc2bb 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.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 .agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md -2026-08-04-configuration-source-ownership.md: f19067abb899e41742f88ce6d17623bc5b82d008 -2026-08-04-configuration-source-ownership.zh.md: a5fd7c61ee71eb9ed9184c3f9c557fb1c3b951ad +2026-08-04-configuration-source-ownership.md: 101c0e6ba4954b3fbb418b775322a9fd92c46a8c +2026-08-04-configuration-source-ownership.zh.md: ad59f9a96e144dd5078898da57195a8bb6897451 diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md index f19067abb8..101c0e6ba4 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md @@ -16,24 +16,35 @@ And `!!js process.env.X` in the shipped composition made the same value reachabl ## Decision -**One ordering, four kinds of source.** Every user-facing value resolves in the same order; the domains differ only in which tiers exist. +**One ordering for non-secret values.** Every configurable value that is not itself a credential resolves in the same order; the domains differ only in which tiers exist. ```text explicit for this run per-operation override, CLI argument -> authored by deployment --config / --config-replace +> user settings settings.yaml +> composition --config / --config-replace, shipped base > this launch's shell inherited process environment -> product-managed store settings.yaml, .credentials.yaml > discovered file $DSH_HOME/.env -> defaults schema default, shipped base, provider public default +> defaults schema default, provider public default ``` -Credentials have no deployment tier (configuration carries a reference, never a value) and no default. Endpoints have every tier. Model selection has CLI, settings, and the shipped default. The earlier proposal ranked a UI-written credential *below* the environment while ranking UI-written settings *above* it; the distinguishing fact is not the domain but who authored the file, so `.credentials.yaml` and `settings.yaml` now sit together, both under the launching shell and both over a discovered `.env`. +Settings sit above composition because that is what the [settings seam](2026-07-28-user-settings-seam.md) does: a plugin registers its cordis entry config as the `base` layer and the user's section layers over it, and the seam cannot tell a value the shipped base set from one a `--config` overlay set — both arrive as entry config. A deployment that must pin a field against a user's stored settings therefore uses `--config-replace`, which bypasses the tree the settings base is derived from. Composition still outranks the environment, so a stale `DEEPSEEK_BASE_URL` in a shell cannot rewrite a configured endpoint. -**The invoking directory's `.env` decides no credential and no route.** `EnvironmentSnapshot.getFrom(name, sources)` searches only the layers a caller names, and omitting one is a refusal rather than a demotion: the adapters ask for `['process', 'user-env']`, so no future reordering can let a project file back into a decision it was excluded from. A project `.env` remains an ordinary environment layer for ordinary variables. +**Credentials keep a narrower, separate ordering**, and this note does not unify them: -**A discovered file may not decide how the process starts.** `isBootstrapOnly` rejects, at load and before anything is materialized, any `.env` that sets a variable governing how a process launches (`PATH`, `SHELL`, `NODE_OPTIONS`, `LD_PRELOAD`, …), where code or model-visible instructions load from (the whole `DSH_*` namespace, `HOME`, `XDG_*`), or how the network is reached and trusted (proxy and CA variables). Matching is case-insensitive, so `https_proxy` is not a bypass. +```text +inherited process environment (read-only, wins) +> $DSH_HOME/.credentials.yaml (provider-managed, writable) +> /.env +> $DSH_HOME/.env +``` -The whole `DSH_*` namespace is denied rather than an audited subset. The harness's own switches — the permission mode, the agents home that holds model-visible skills, the bundled skill root — are exactly what a hostile project would reach for, and a switch added later must not become settable by being forgotten. There is no opt-out: an escape hatch would have to be readable from somewhere, and anything a discovered file could set is the hole itself. +The launching environment wins because `DEEPSEEK_API_KEY=… dsh`, a CI secret, and a container `-e` are the one override an operator must be able to apply per run without editing machine state, and because it cannot be edited from inside it must be *visibly* read-only. Configuration is meant to carry only the *reference* — which name to resolve — and that name follows the non-secret ordering above. + +**The project the harness is launched in is trusted, by default and without a prompt.** A checkout may carry its own endpoint, its own ordinary variables, and its own key; the key ranks below the managed store, so a key stored through the web page or TUI is never displaced by one a checkout happens to contain. `EnvironmentSnapshot.getFrom(name, sources)` still searches only the layers a caller names, and omitting one is a refusal rather than a demotion — the mechanism exists for the decisions where a layer must be unreachable, not because the project is one of them today. + +**Trust does not extend to changing the harness itself.** `isBootstrapOnly` rejects, at load and before anything is materialized, any `.env` that sets a variable governing how a process launches (`PATH`, `SHELL`, `NODE_OPTIONS`, `LD_PRELOAD`), what code a runtime executes before the program it was asked to run (`BASH_ENV`, `PERL5OPT`, `PYTHONSTARTUP`, `RUBYOPT`, `JAVA_TOOL_OPTIONS`, the Git hook commands), where model-visible instructions load from (the whole `DSH_*` namespace, `HOME`, `XDG_*`), or how the network is reached and trusted (proxy and CA variables). Matching is case-insensitive, so `https_proxy` is not a bypass. + +The line is that these take effect with no user action, before any turn, outside the permission policy and the sandbox. `DSH_PERMISSION_MODE` would switch off the approvals that make trusting a project meaningful at all, and `BASH_ENV` runs a file of the project's choosing on every single `bash -c` the bash tool issues — the project's code running under the agent's policy is the deal; the project rewriting that policy is not. Enumerating these is a losing game one variable at a time, which is why the whole `DSH_*` namespace is denied rather than an audited subset, and why the list is organised by what a variable *does* rather than by which runtime owns it. There is no opt-out: an escape hatch would have to be readable from somewhere, and anything a discovered file could set is the hole itself. **`packages/util/environment` owns the snapshot**, deliberately as a utility rather than a three-package capability seam. The snapshot is frozen before Cordis starts and injected once by the launcher, so there is no runtime implementation to swap; consumers need types and pure functions, which a `util/` package gives them without depending on a UI package. `environmentOf(ctx)` returns the launcher's snapshot, or the inherited environment as the only layer — an SDK host or bare `cordis.yml` discovered no files, so its single layer really is what it was launched with, and the same trusted lookups keep working there unchanged. @@ -43,16 +54,16 @@ The whole `DSH_*` namespace is denied rather than an audited subset. The harness - The web credential form now takes effect against an older key in the user's `.env`; only a key exported in the launching shell still makes it read-only, and the diagnostic says so. - A `.env` holding `DSH_*`, `PATH`, or a proxy variable fails the launch instead of being applied. Developers keeping switches in a repository `.env` move them to their shell — a deliberate, loud break. -- `--config` is no longer overridable by a stale shell endpoint, so a deployment can pin an enterprise gateway. -- Given up: an endpoint or key in the invoking directory's `.env` no longer applies. Per-project routing is a `--config` overlay or an `export` in that project's shell. +- `--config` is no longer overridable by a stale shell endpoint. It is still overridable by a user's stored `settings.yaml`, which is the settings seam's layering and not something this note changes; a deployment that must win against stored settings uses `--config-replace`. - Not solved: the layers are still materialized into `process.env`, so ordinary project variables continue to reach child processes under the subprocess scrub. Bootstrap variables cannot come from a file at all, which closes the escalation path; a project `.env` setting something like `GIT_SSH_COMMAND` for the tools an agent runs remains possible and is recorded as a limitation on the package. +- The adapters no longer accept a literal `apiKey`: configuration carries the reference and nothing else, so a settings document cannot become a second credential store. No adapter namespace is strict, so writing one is dropped rather than rejected. - Exa and Perplexity still capture their key at load time rather than through the credential seam. They no longer read raw `process.env` — they resolve through the trusted layers — but converting them to per-request seam resolution is separate work. ## Alternatives considered -**Keep the proposal's split ladders (credentials env-over-file, endpoints settings-over-env).** Rejected on its own inconsistency: both arguments — "an export is this run's intent" and "a deployment's file should not be rewritten by a stale shell" — apply to both domains. Sorting by *who authored the source* explains both and produces one table instead of four. +**Unify credentials into the non-secret ordering, by who authored each source.** Attempted and abandoned: it reads well, but the settings seam already fixes composition *below* the user section, so "authored by deployment" is not a tier the seam can express — and moving `.credentials.yaml` above the launching environment would take away the one override CI, containers, and a per-run `DEEPSEEK_API_KEY=…` depend on. Two orderings that each say why they are shaped that way beat one that describes neither accurately. -**Let the invoking directory's `.env` supply a credential, ranked below the managed store.** Rejected: with no key stored, a hostile project's key would be used silently, and the account holder reads every prompt sent under it. That is the same exfiltration the endpoint rule exists to prevent, so it takes the same answer. +**Withhold routing and credentials from the invoking project until it is explicitly trusted.** Rejected as the product's stance: a checkout is trusted by default, with no prompt and no stored trust record. The residual is real and worth naming — cloning a repository that carries a `.env` naming another endpoint or key routes that session through it — and a later project-trust gate is where that gets addressed, not a rule that makes the common case require ceremony. **Audit an allowlist of `DSH_*` variables a `.env` may set.** Rejected: the list would have to be re-audited on every new switch, and the failure mode of forgetting is silent. Denying the namespace fails safe. diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md index a5fd7c61ee..ad59f9a96e 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md @@ -16,26 +16,37 @@ endpoint 可以被项目重定向。调用目录的 `.env` 和其他层一样会 ## Decision -**一条顺序,四类来源。** 每个面向用户的值按同一顺序解析;各领域的差别只在于哪些层存在。 +**非密钥值走同一条顺序。** 每个本身不是凭据的可配置值都按同一顺序解析;各领域的差别只在于哪些层存在。 ```text explicit for this run per-operation override, CLI argument -> authored by deployment --config / --config-replace +> user settings settings.yaml +> composition --config / --config-replace, shipped base > this launch's shell inherited process environment -> product-managed store settings.yaml, .credentials.yaml > discovered file $DSH_HOME/.env -> defaults schema default, shipped base, provider public default +> defaults schema default, provider public default ``` -自上而下依次是:本次运行的显式意图、部署授权、本次启动的 shell、产品受管存储、被发现的文件、默认值。 +自上而下依次是:本次运行的显式意图、用户 settings、composition、本次启动的 shell、被发现的文件、默认值。 -凭据没有部署层(配置携带引用,从不携带值),也没有默认值层。endpoint 拥有全部层。模型选择只有 CLI、settings 与已交付默认值。此前的方案把 UI 写入的凭据排在环境*之下*,却把 UI 写入的 settings 排在环境*之上*;真正的区分依据不是领域,而是这个文件由谁书写,因此 `.credentials.yaml` 与 `settings.yaml` 现在并列,同在启动 shell 之下、同在被发现的 `.env` 之上。 +settings 在 composition 之上,因为 [settings seam](2026-07-28-user-settings-seam.md) 就是这么做的:插件把自己的 cordis entry config 注册为 `base` 层,用户 section 叠加其上,而 seam 无法区分某个值是交付基座设的还是 `--config` overlay 设的——两者都以 entry config 的形式抵达。因此,需要把某字段钉死、不被用户已存 settings 覆盖的部署方,应使用 `--config-replace`,它绕过了 settings base 所派生的那棵树。composition 仍然高于环境,所以 shell 里陈旧的 `DEEPSEEK_BASE_URL` 无法改写已配置的 endpoint。 -**调用目录的 `.env` 不决定任何凭据与路由。** `EnvironmentSnapshot.getFrom(name, sources)` 只搜索调用方点名的层,省略某层是拒绝而不是降级:适配器请求的是 `['process', 'user-env']`,因此后续任何重新排序都无法让项目文件重新进入一个它被排除在外的决策。对普通变量而言,项目 `.env` 仍然是普通环境层。 +**凭据保留一条更窄的独立顺序**,本 Note 不把它并入上表: -**被发现的文件不得决定进程如何启动。** `isBootstrapOnly` 会在加载时、且在物化任何内容之前,拒绝任何设置了下列变量的 `.env`:决定进程如何启动的(`PATH`、`SHELL`、`NODE_OPTIONS`、`LD_PRELOAD` 等)、决定代码或模型可见指令从哪里加载的(整个 `DSH_*` 命名空间、`HOME`、`XDG_*`),以及决定网络如何抵达与信任的(proxy 与 CA 变量)。匹配不区分大小写,因此 `https_proxy` 不是绕过手段。 +```text +inherited process environment (read-only, wins) +> $DSH_HOME/.credentials.yaml (provider-managed, writable) +> /.env +> $DSH_HOME/.env +``` -被拒绝的是整个 `DSH_*` 命名空间,而不是一份经过审查的子集。harness 自己的开关——权限模式、存放模型可见 skill(技能)的 agents home、内置 skill 根目录——恰恰是敌意项目最想伸手的地方,而后来新增的开关不能因为被遗忘就变得可设置。不设逃生门:逃生门本身总得从某处读取,而任何被发现的文件能设置的东西,就是那个漏洞本身。 +继承环境优先,因为 `DEEPSEEK_API_KEY=… dsh`、CI 机密与容器 `-e` 是运维必须能按次施加、且无需改动机器状态的那一种覆盖;而它无法从进程内部修改,就必须*可见地*只读。配置本应只携带*引用*——解析哪个名字——该名字本身遵循上面的非密钥顺序。 + +**harness 被启动于其中的项目默认可信,且不做询问。** 一个 checkout 可以携带自己的 endpoint、自己的普通变量和自己的密钥;密钥排在受管存储之下,因此通过 Web 页面或 TUI 存下的密钥绝不会被 checkout 中恰好带有的那一个顶掉。`EnvironmentSnapshot.getFrom(name, sources)` 仍然只搜索调用方点名的层,省略某层仍是拒绝而不是降级——该机制是为「某一层必须不可达」的那些决策准备的,而项目层今天不在其列。 + +**信任不延伸到改变 harness 本身。** `isBootstrapOnly` 会在加载时、且在物化任何内容之前,拒绝任何设置了下列变量的 `.env`:决定进程如何启动的(`PATH`、`SHELL`、`NODE_OPTIONS`、`LD_PRELOAD`)、决定运行时在执行被要求运行的程序之前先执行哪些代码的(`BASH_ENV`、`PERL5OPT`、`PYTHONSTARTUP`、`RUBYOPT`、`JAVA_TOOL_OPTIONS`、Git 的钩子命令)、决定模型可见指令从哪里加载的(整个 `DSH_*` 命名空间、`HOME`、`XDG_*`),以及决定网络如何抵达与信任的(proxy 与 CA 变量)。匹配不区分大小写,因此 `https_proxy` 不是绕过手段。 + +这条界线在于:它们无需任何用户动作、在任何一轮开始之前、且在权限策略与沙箱之外就生效。`DSH_PERMISSION_MODE` 会关掉让「信任项目」根本成立的那道审批,而 `BASH_ENV` 会在 bash 工具发出的每一次 `bash -c` 上执行项目指定的文件——项目的代码在 agent 的策略下运行是约定,项目改写那份策略不是。一个变量一个变量地枚举是必输的游戏,所以整个 `DSH_*` 命名空间被拒绝而不是只拒绝一份经审查的子集,也所以这份清单是按变量*做什么*而不是按哪个运行时拥有它来组织的。不设逃生门:逃生门本身总得从某处读取,而任何被发现的文件能设置的东西,就是那个漏洞本身。 **`packages/util/environment` 拥有该快照**,刻意做成 utility 而不是三包能力 seam。快照在 Cordis 启动前就冻结,并由启动器一次性注入,因此不存在需要切换的运行时实现;消费方需要的只是类型和纯函数,而 `util/` 包能提供这些且不必依赖 UI 包。`environmentOf(ctx)` 返回启动器的快照,或者返回只含继承环境的那一层——SDK 宿主或裸 `cordis.yml` 从未发现过任何文件,它那唯一一层确实就是它被启动时的环境,因此同样的受信查询在那里原样继续工作。 @@ -45,16 +56,16 @@ explicit for this run per-operation override, CLI argument - Web 凭据表单现在能压过用户 `.env` 里更旧的密钥;只有在启动 shell 里 export 的密钥才会让它变成只读,诊断信息也会这么说。 - 含 `DSH_*`、`PATH` 或 proxy 变量的 `.env` 会导致启动失败而不是被应用。把开关放在仓库 `.env` 里的开发者需要改放到 shell——这是一次刻意且响亮的破坏。 -- `--config` 不再会被陈旧的 shell endpoint 覆盖,因此部署方可以钉住企业网关。 -- 放弃的:调用目录 `.env` 里的 endpoint 或密钥不再生效。按项目切换路由请用 `--config` overlay 或该项目 shell 里的 `export`。 +- `--config` 不再会被陈旧的 shell endpoint 覆盖。但它仍然会被用户已存的 `settings.yaml` 覆盖,这是 settings seam 的分层方式,本 Note 不改变它;需要压过已存 settings 的部署方应使用 `--config-replace`。 - 未解决的:各层仍然会被物化进 `process.env`,因此普通项目变量继续按子进程清洗规则抵达子进程。bootstrap 变量完全不能来自文件,提权路径已封闭;项目 `.env` 为 agent 运行的工具设置诸如 `GIT_SSH_COMMAND` 之类的变量仍然可能,已作为限制记录在该包上。 +- 适配器不再接受字面 `apiKey`:配置只携带引用,因此 settings 文档无法成为第二个凭据存储。由于没有任何适配器 namespace 是 strict 的,写入该键会被 schema 丢弃而不是报错。 - Exa 与 Perplexity 仍在加载时捕获密钥,而不是经凭据 seam。它们不再读裸 `process.env`——改为经受信层解析——但把它们改造成按请求经 seam 解析是另一件事。 ## Alternatives considered -**沿用方案里分开的两条 ladder(凭据环境压过文件、endpoint settings 压过环境)。** 因其自身的不自洽而否决:两条理由——「export 是本次运行的意图」和「部署方的文件不该被陈旧 shell 改写」——对两个领域同样成立。按*来源由谁书写*排序能同时解释两者,并且把四张表变成一张。 +**按「来源由谁书写」把凭据并入非密钥顺序。** 尝试过并放弃:它读起来很顺,但 settings seam 已经把 composition 固定在用户 section *之下*,因此「部署授权」根本不是该 seam 能表达的一层;而把 `.credentials.yaml` 抬到启动环境之上,会夺走 CI、容器和一次性 `DEEPSEEK_API_KEY=…` 所依赖的那唯一一种覆盖。两条各自说清自身形状成因的顺序,好过一条两边都描述不准的顺序。 -**允许调用目录 `.env` 提供凭据,排在受管存储之下。** 否决:在没有存储密钥时,敌意项目的密钥会被静默使用,而该账号持有者能读到以它发出的每一条提示词。这与 endpoint 规则要防的外泄是同一件事,因此答案也相同。 +**在项目被显式信任之前,不给它路由与凭据能力。** 作为产品立场被否决:checkout 默认可信,不询问,也不存储信任记录。残留风险是真实的、值得写明——克隆一个携带 `.env`、其中指定了另一个 endpoint 或密钥的仓库,会让该会话经由它——处理它的地方是日后的 project trust 门禁,而不是一条让常见情形都要走仪式的规则。 **审查出一份 `.env` 可设置的 `DSH_*` 白名单。** 否决:每新增一个开关都要重新审查,而遗漏的失败模式是静默的。拒绝整个命名空间是 fail safe。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 44888f182a..9240454926 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -423,7 +423,7 @@ export interface Config { } ``` -Source: [`packages/credentials/credentials-local/src/index.ts:54`](../packages/credentials/credentials-local/src/index.ts) +Source: [`packages/credentials/credentials-local/src/index.ts:55`](../packages/credentials/credentials-local/src/index.ts) ## `@deepseek-ai/dsh-fs-local` @@ -628,8 +628,6 @@ Requires: `llm` * reasoning effort resolves to `high`. */ export interface Config { - /** 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 from a trusted environment layer, then the public API. */ diff --git a/examples/headless-agent/tests/fixtures/deepseek-defaults.cordis.yml b/examples/headless-agent/tests/fixtures/deepseek-defaults.cordis.yml index cd472f737d..c501901604 100644 --- a/examples/headless-agent/tests/fixtures/deepseek-defaults.cordis.yml +++ b/examples/headless-agent/tests/fixtures/deepseek-defaults.cordis.yml @@ -5,7 +5,6 @@ patches: - id: llm-deepseek config: - apiKey: snapshot-key baseURL: !!js process.env.DSH_SNAPSHOT_BASE_URL thinking: disabled - id: cli-agent diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts index 8b48165a83..29493b307a 100644 --- a/examples/headless-agent/tests/headless.snapshot.ts +++ b/examples/headless-agent/tests/headless.snapshot.ts @@ -244,13 +244,12 @@ describe('headless stream-json snapshots', () => { prepare: (cwd) => { runCwd = cwd }, }) - // The guidance leads with the credential store — the path that keeps the - // secret out of configuration files — and offers a literal key last. + // The guidance names both places a credential can come from, and nothing + // else: configuration carries the reference, never a literal key. expect(result.stderr).toBe( 'dsh-cli-demo: turn 1 failed at step 1: llm-deepseek: no API key for provider route "deepseek-official";' + ' store DEEPSEEK_API_KEY through the credentials service (the web Models page writes it),' - + ' export DEEPSEEK_API_KEY in the launching environment, or — as a last resort — set a literal' - + ' "apiKey" in the llm-deepseek settings section\n', + + ' or export DEEPSEEK_API_KEY in the launching environment\n', ) const normalized = normalizeHeadlessStream(result.stdout, runCwd) if (refreshing) await writeFile(streamExpected, normalized) @@ -314,6 +313,9 @@ describe('headless stream-json snapshots', () => { ], tsconfigPath, env: { + // Configuration carries only the reference; the key rides the + // launching environment, which is the whole credential plane here. + DEEPSEEK_API_KEY: 'snapshot-key', DSH_SNAPSHOT_BASE_URL: server.url, NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '), }, 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 index 4f3bcd2321..2ca5c63dc9 100644 --- a/examples/headless-agent/tests/snapshots/missing-credential/stream-json.expected.jsonl +++ b/examples/headless-agent/tests/snapshots/missing-credential/stream-json.expected.jsonl @@ -5,5 +5,5 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":5,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":1000000}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":6,"time":0,"data":{"turn":1,"step":1}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":7,"time":0,"data":{"turn":1,"reason":{"kind":"error","step":1,"failure":{"message":"llm-deepseek: no API key for provider route \"deepseek-official\"; store DEEPSEEK_API_KEY through the credentials service (the web Models page writes it), export DEEPSEEK_API_KEY in the launching environment, or — as a last resort — set a literal \"apiKey\" in the llm-deepseek settings section","code":"MISSING_CREDENTIAL"}}}}} -{"type":"result","success":false,"sessionId":"{{sessionId}}","turn":1,"result":"","reason":{"kind":"error","step":1,"failure":{"message":"llm-deepseek: no API key for provider route \"deepseek-official\"; store DEEPSEEK_API_KEY through the credentials service (the web Models page writes it), export DEEPSEEK_API_KEY in the launching environment, or — as a last resort — set a literal \"apiKey\" in the llm-deepseek settings section","code":"MISSING_CREDENTIAL"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":7,"time":0,"data":{"turn":1,"reason":{"kind":"error","step":1,"failure":{"message":"llm-deepseek: no API key for provider route \"deepseek-official\"; store DEEPSEEK_API_KEY through the credentials service (the web Models page writes it), or export DEEPSEEK_API_KEY in the launching environment","code":"MISSING_CREDENTIAL"}}}}} +{"type":"result","success":false,"sessionId":"{{sessionId}}","turn":1,"result":"","reason":{"kind":"error","step":1,"failure":{"message":"llm-deepseek: no API key for provider route \"deepseek-official\"; store DEEPSEEK_API_KEY through the credentials service (the web Models page writes it), or export DEEPSEEK_API_KEY in the launching environment","code":"MISSING_CREDENTIAL"}}} diff --git a/packages/credentials/credentials-local/src/index.ts b/packages/credentials/credentials-local/src/index.ts index 6d5db0776f..1f0f550c05 100644 --- a/packages/credentials/credentials-local/src/index.ts +++ b/packages/credentials/credentials-local/src/index.ts @@ -3,9 +3,10 @@ * against the environment by how much each layer is trusted: * * ```text - * inherited process environment (read-only, wins) - * > $DSH_HOME/.credentials.yaml (provider-managed, writable) - * > $DSH_HOME/.env (read-only fallback) + * inherited process environment (read-only, wins) + * > $DSH_HOME/.credentials.yaml (provider-managed, writable) + * > /.env (read-only fallback) + * > $DSH_HOME/.env (read-only fallback) * ``` * * The inherited environment wins because `DEEPSEEK_API_KEY=… dsh`, a CI @@ -15,10 +16,10 @@ * web page or TUI writes takes effect immediately even when an older key sits * in the user's `.env`. * - * The invoking directory's `.env` supplies no credential at all. A project - * directory can be written by the model, and a substituted key would send - * every request — prompts included — through an account someone else reads; - * that decision belongs to the launching shell, not to a discovered file. + * The invoking project may supply a key, because the product trusts the + * project it is launched in. It ranks below the managed store, so a key stored + * through the web page or TUI is never displaced by one a checkout happens to + * carry. * * The file is the provider-managed writable source: every write re-reads the * document under a cross-process writer lock before patching only its own key @@ -37,7 +38,7 @@ import { Context, Service } from 'cordis' import z from 'schemastery' import { watch as chokidarWatch } from 'chokidar' -import { mkdir, readFile } from 'node:fs/promises' +import { mkdir, readFile, stat } from 'node:fs/promises' import { dirname, join, resolve } from 'node:path' import { Document, parseDocument } from 'yaml' import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' @@ -83,11 +84,56 @@ export function resolveSpec(config: Config): ResolvedSpec { } } +/** Permission bits outside the owner; a credentials document must have none of them. */ +const GROUP_OTHER_BITS = 0o077 + +/** + * Reject a credentials document other OS users can read, before its contents + * are read at all. The provider creates and replaces the file at `0600`, but a + * hand-written or externally generated one carries whatever umask produced it, + * and silently serving secrets out of a world-readable file would make the + * mode the provider promises meaningless. + * + * POSIX only: Windows has no mode to inspect — its ACLs are not expressible + * here — so the check is skipped rather than faked, and the file's protection + * there is whatever the create and replace APIs express. + * @param filename - absolute path of the document. + * @throws when the file exists with group or other permission bits set. + */ +async function assertOwnerOnly(filename: string): Promise { + if (process.platform === 'win32') return + let mode: number + try { + mode = (await stat(filename)).mode + } catch (error) { + if (!isENOENT(error)) throw error + return + } + const offending = mode & GROUP_OTHER_BITS + if (offending === 0) return + throw new Error( + `credentials-local: ${filename} is readable beyond its owner (mode ${(mode & 0o777).toString(8)});` + + ` run "chmod 600 ${filename}" before starting again`, + ) +} + /** Whether a filesystem error means absence; every non-ENOENT failure must surface. */ function isENOENT(error: unknown): boolean { return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT' } +/** + * Describe one YAML parse failure without quoting the source. The parser's own + * message embeds the offending line, which here holds a secret. + * @param error - the parser's error. + * @returns the error code with its line and column. + */ +function describeYamlError(error: { code?: string; linePos?: [{ line: number; col: number }, ...unknown[]] }): string { + const at = error.linePos?.[0] + const where = at === undefined ? '' : ` at line ${String(at.line)}, column ${String(at.col)}` + return `${error.code ?? 'YAML_ERROR'}${where}` +} + /** * Parse one credentials document into its entries. The document is a strict * mapping of {@link CredentialRef} to non-empty string: a non-mapping root, a @@ -101,10 +147,15 @@ function isENOENT(error: unknown): boolean { * @returns the parsed entries, keyed by reference. */ export function parseCredentialsDocument(text: string, filename: string): Map { + // `prettyErrors` is on only for `linePos`; `error.message` is never used, + // because the parser quotes the offending source line and in this document + // that line is a secret. Only the code and position leave this function, and + // the same rule governs every other diagnostic here — a key name is safe to + // print, a value is not. const document = parseDocument(text, { prettyErrors: true, uniqueKeys: true }) if (document.errors.length > 0) { throw new Error(`credentials-local: invalid document at ${filename}: ${ - document.errors.map(error => error.message).join('; ')}`) + document.errors.map(describeYamlError).join('; ')}`) } const root: unknown = document.toJS() ?? {} if (typeof root !== 'object' || root === null || Array.isArray(root)) { @@ -116,6 +167,8 @@ export function parseCredentialsDocument(text: string, filename: string): Map 0 ? entry.value : undefined } - /** The user `.env` fallback for a reference — below the managed store, never above it. */ - private userEnvFallback(ref: CredentialRef): EnvironmentEntry | undefined { - const entry = environmentOf(this.ctx).getFrom(ref, ['user-env']) + /** + * The `.env` fallback for a reference — below the managed store, never above + * it. The invoking project ranks over the user's home file, matching the + * environment layering: the more specific location wins. + */ + private dotenvFallback(ref: CredentialRef): EnvironmentEntry | undefined { + const entry = environmentOf(this.ctx).getFrom(ref, ['project-env', 'user-env']) return entry !== undefined && entry.value.length > 0 ? entry : undefined } @@ -249,8 +306,8 @@ export class CredentialsLocal extends Credentials { if (inherited !== undefined) return Promise.resolve({ value: inherited, source: 'env' }) const stored = this.values.get(ref) if (stored !== undefined) return Promise.resolve({ value: stored, source: 'file' }) - const fallback = this.userEnvFallback(ref) - if (fallback !== undefined) return Promise.resolve({ value: fallback.value, source: 'user-env' }) + const fallback = this.dotenvFallback(ref) + if (fallback !== undefined) return Promise.resolve({ value: fallback.value, source: fallback.source }) return Promise.resolve(undefined) } @@ -263,9 +320,8 @@ export class CredentialsLocal extends Credentials { } const stored = this.values.get(ref) if (stored !== undefined) return Promise.resolve({ configured: true, source: 'file', writable: true }) - if (this.userEnvFallback(ref) !== undefined) { - return Promise.resolve({ configured: true, source: 'user-env', writable: true }) - } + const fallback = this.dotenvFallback(ref) + if (fallback !== undefined) return Promise.resolve({ configured: true, source: fallback.source, writable: true }) return Promise.resolve({ configured: false, writable: true }) } @@ -361,6 +417,7 @@ export class CredentialsLocal extends Credentials { * cannot be trusted must never be treated as "no credentials stored". */ private async loadInitial(): Promise { + await assertOwnerOnly(this.spec.filename) let text: string try { text = await readFile(this.spec.filename, 'utf8') @@ -401,6 +458,9 @@ export class CredentialsLocal extends Credentials { * overwriting a document it could not understand. */ private async reconcileFromDisk(): Promise { + // Re-checked on every reload and before every write: an external editor or + // a restored backup can loosen the mode after boot. + await assertOwnerOnly(this.spec.filename) let text: string | undefined try { text = await readFile(this.spec.filename, 'utf8') diff --git a/packages/credentials/credentials-local/tests/local.spec.ts b/packages/credentials/credentials-local/tests/local.spec.ts index abc3521111..7a8b8fdc17 100644 --- a/packages/credentials/credentials-local/tests/local.spec.ts +++ b/packages/credentials/credentials-local/tests/local.spec.ts @@ -8,6 +8,11 @@ import { createEnvironmentSnapshot, DSH_ENVIRONMENT_KEY } from '@deepseek-ai/dsh import type { CredentialRef } from '@deepseek-ai/dsh-credentials' import { CredentialsLocal, resolveSpec } from '../src/index.ts' +/** Credential documents are seeded owner-only, exactly as the provider creates them. */ +function writeCredentials(file: string, text: string): Promise { + return writeFile(file, text, { mode: 0o600 }) +} + const KEY = credentialRef('DSH_CRED_TEST') const OTHER = credentialRef('DSH_CRED_OTHER') @@ -65,7 +70,7 @@ describe('layering and reads', () => { it('serves file entries alongside comments and quoted values', async () => { const dir = await tempDir() const path = join(dir, '.credentials.yaml') - await writeFile(path, '# notes\nDSH_CRED_TEST: plain\nDSH_CRED_OTHER: "with space"\n') + await writeCredentials(path, '# notes\nDSH_CRED_TEST: plain\nDSH_CRED_OTHER: "with space"\n') const ctx = await boot({ path, watch: false }) expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'plain', source: 'file' }) expect(await ctx.credentials.resolve(OTHER)).toEqual({ value: 'with space', source: 'file' }) @@ -75,7 +80,7 @@ describe('layering and reads', () => { it('lets a non-empty process environment win read-only over the file', async () => { const dir = await tempDir() const path = join(dir, '.credentials.yaml') - await writeFile(path, 'DSH_CRED_TEST: from-file\n') + await writeCredentials(path, 'DSH_CRED_TEST: from-file\n') const ctx = await boot({ path, watch: false }) vi.stubEnv('DSH_CRED_TEST', 'from-env') expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'from-env', source: 'env' }) @@ -85,7 +90,7 @@ describe('layering and reads', () => { it('treats an empty environment value as absent, falling through to the file', async () => { const dir = await tempDir() const path = join(dir, '.credentials.yaml') - await writeFile(path, 'DSH_CRED_TEST: stored\n') + await writeCredentials(path, 'DSH_CRED_TEST: stored\n') const ctx = await boot({ path, watch: false }) vi.stubEnv('DSH_CRED_TEST', '') expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'stored', source: 'file' }) @@ -119,7 +124,7 @@ describe('layer ladder', () => { it('lets the stored value beat the user .env, so a UI write takes effect immediately', async () => { const dir = await tempDir() const path = join(dir, '.credentials.yaml') - await writeFile(path, 'DSH_CRED_TEST: stored\n') + await writeCredentials(path, 'DSH_CRED_TEST: stored\n') const ctx = await bootLayered(path, [ { source: 'process', values: {} }, { source: 'user-env', path: '/home/.dsh/.env', values: { DSH_CRED_TEST: 'older-user-env' } }, @@ -143,22 +148,41 @@ describe('layer ladder', () => { expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'user-env', writable: true }) }) - it('ignores the invoking directory .env entirely', async () => { + it('serves the invoking project .env over the user one, but never over the store', async () => { const dir = await tempDir() - const ctx = await bootLayered(join(dir, '.credentials.yaml'), [ - { source: 'process', values: {} }, - { source: 'project-env', path: '/work/.env', values: { DSH_CRED_TEST: 'from-project' } }, - ]) - // A project directory can be written by the model, and a substituted key - // would route every request through an account someone else reads. - expect(await ctx.credentials.resolve(KEY)).toBeUndefined() - expect(await ctx.credentials.describe(KEY)).toEqual({ configured: false, writable: true }) + const path = join(dir, '.credentials.yaml') + // The product trusts the project it is launched in, so a checkout may + // carry its own key — ranked above the user's home file (more specific + // wins) and below the managed store, which a stored key must never lose to. + const layers = [ + { source: 'process' as const, values: {} }, + { source: 'project-env' as const, path: '/work/.env', values: { DSH_CRED_TEST: 'from-project' } }, + { source: 'user-env' as const, path: '/home/.dsh/.env', values: { DSH_CRED_TEST: 'from-user' } }, + ] + const bare = await bootLayered(path, layers) + expect(await bare.credentials.resolve(KEY)).toEqual({ value: 'from-project', source: 'project-env' }) + expect(await bare.credentials.describe(KEY)).toEqual({ configured: true, source: 'project-env', writable: true }) + + await writeCredentials(path, 'DSH_CRED_TEST: stored\n') + const stored = await bootLayered(path, layers) + expect(await stored.credentials.resolve(KEY)).toEqual({ value: 'stored', source: 'file' }) + }) + + it('refuses a document other OS users can read', async () => { + const dir = await tempDir() + const path = join(dir, '.credentials.yaml') + await writeFile(path, 'DSH_CRED_TEST: leaked\n', { mode: 0o644 }) + const ctx = new Context() + // Before the contents are read at all: serving secrets out of a + // world-readable file would make the 0600 the provider writes meaningless. + await expect(ctx.plugin(CredentialsLocal, { path, watch: false })) + .rejects.toThrow(/readable beyond its owner \(mode 644\)/) }) it('lets only the inherited environment shadow the store, read-only', async () => { const dir = await tempDir() const path = join(dir, '.credentials.yaml') - await writeFile(path, 'DSH_CRED_TEST: stored\n') + await writeCredentials(path, 'DSH_CRED_TEST: stored\n') const ctx = await bootLayered(path, [ { source: 'process', values: { DSH_CRED_TEST: 'from-shell' } }, { source: 'user-env', path: '/home/.dsh/.env', values: { DSH_CRED_TEST: 'from-user-env' } }, @@ -184,15 +208,36 @@ describe('document validation', () => { ])('fails boot on %s', async (_case, text, message) => { const dir = await tempDir() const path = join(dir, '.credentials.yaml') - await writeFile(path, text) + await writeCredentials(path, text) const ctx = new Context() await expect(ctx.plugin(CredentialsLocal, { path, watch: false })).rejects.toThrow(message) }) + it('never puts a credential value in a diagnostic', async () => { + const dir = await tempDir() + const path = join(dir, '.credentials.yaml') + const secret = 'sk-live-DO-NOT-LOG-abcdef123456' + // The yaml parser's own message quotes the offending source line, which in + // this document is the secret itself. Boot stderr and the watcher's logger + // both receive whatever this throws. + await writeCredentials(path, `DSH_CRED_TEST: "${secret}\n`) + let failure: unknown + try { + await new Context().plugin(CredentialsLocal, { path, watch: false }) + } catch (error) { + failure = error + } + expect(String(failure)).toMatch(/invalid document/) + // The position survives; the line's contents do not. + expect(String(failure)).toMatch(/line 2, column 1/) + expect(String(failure)).not.toContain(secret) + expect((failure as Error).stack ?? '').not.toContain(secret) + }) + it('reads an empty document as an empty store', async () => { const dir = await tempDir() const path = join(dir, '.credentials.yaml') - await writeFile(path, '# nothing stored yet\n') + await writeCredentials(path, '# nothing stored yet\n') const ctx = await boot({ path, watch: false }) expect(await ctx.credentials.resolve(KEY)).toBeUndefined() }) @@ -214,7 +259,7 @@ describe('document writes', () => { it('patches one entry, preserving comments and every untouched entry', async () => { const dir = await tempDir() const path = join(dir, '.credentials.yaml') - await writeFile(path, '# deployment notes\nDSH_CRED_OTHER: keep\n\n# the one under edit\nDSH_CRED_TEST: old\n') + await writeCredentials(path, '# deployment notes\nDSH_CRED_OTHER: keep\n\n# the one under edit\nDSH_CRED_TEST: old\n') const ctx = await boot({ path, watch: false }) await ctx.credentials.set(KEY, 'new value!') expect(await readFile(path, 'utf8')).toBe( @@ -242,7 +287,7 @@ describe('document writes', () => { // Comments above an entry are that entry's annotation and go with it when // it is removed — including anything above the document's first entry. // Every other entry keeps its own comments. - await writeFile(path, '# about the doomed one\nDSH_CRED_TEST: gone\n# about the survivor\nDSH_CRED_OTHER: stays\n') + await writeCredentials(path, '# about the doomed one\nDSH_CRED_TEST: gone\n# about the survivor\nDSH_CRED_OTHER: stays\n') const ctx = await boot({ path, watch: false }) const seen = updates(ctx) await ctx.credentials.unset(KEY) @@ -254,7 +299,7 @@ describe('document writes', () => { it('rejects empty values and writes the environment would shadow', async () => { const dir = await tempDir() const path = join(dir, '.credentials.yaml') - await writeFile(path, 'DSH_CRED_TEST: stored\n') + await writeCredentials(path, 'DSH_CRED_TEST: stored\n') const ctx = await boot({ path, watch: false }) await expect(ctx.credentials.set(KEY, '')).rejects.toThrow(/empty value/) @@ -267,7 +312,7 @@ describe('document writes', () => { it('leaves an empty mapping after unsetting the only entry', async () => { const dir = await tempDir() const path = join(dir, '.credentials.yaml') - await writeFile(path, 'DSH_CRED_TEST: only\n') + await writeCredentials(path, 'DSH_CRED_TEST: only\n') const ctx = await boot({ path, watch: false }) await ctx.credentials.unset(KEY) expect(await readFile(path, 'utf8')).toBe('{}\n') @@ -282,7 +327,7 @@ describe('document writes', () => { const ctx = await boot({ path, watch: false }) // An external editor left the document unparsable: the read-modify-write // must refuse rather than overwrite content it cannot understand. - await writeFile(path, 'DSH_CRED_TEST: "unterminated\n') + await writeCredentials(path, 'DSH_CRED_TEST: "unterminated\n') await expect(ctx.credentials.set(OTHER, 'lands')).rejects.toThrow(/invalid document/) }) @@ -326,17 +371,17 @@ describe('real hot reload', () => { const path = join(dir, '.credentials.yaml') // Watching starts on an existing document: creation racing watcher setup // is a chokidar readiness gap, not the reload contract under test. - await writeFile(path, 'DSH_CRED_TEST: boot\n') + await writeCredentials(path, 'DSH_CRED_TEST: boot\n') const ctx = await boot({ path, debounceMs: 10 }) const seen = updates(ctx) - await writeFile(path, 'DSH_CRED_TEST: live\nDSH_CRED_OTHER: extra\n') + await writeCredentials(path, 'DSH_CRED_TEST: live\nDSH_CRED_OTHER: extra\n') await vi.waitFor(async () => { expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'live', source: 'file' }) }) // Wholesale replacement: an entry deleted on disk never lingers in memory. - await writeFile(path, 'DSH_CRED_TEST: live\n') + await writeCredentials(path, 'DSH_CRED_TEST: live\n') await vi.waitFor(async () => { expect(await ctx.credentials.resolve(OTHER)).toBeUndefined() }) diff --git a/packages/credentials/credentials-local/tests/review-fixes.spec.ts b/packages/credentials/credentials-local/tests/review-fixes.spec.ts index 7d2f447e5a..fcec7fceb9 100644 --- a/packages/credentials/credentials-local/tests/review-fixes.spec.ts +++ b/packages/credentials/credentials-local/tests/review-fixes.spec.ts @@ -10,6 +10,11 @@ import { join } from 'node:path' import { credentialRef } from '@deepseek-ai/dsh-credentials' import { CredentialsLocal } from '../src/index.ts' +/** Credential documents are seeded owner-only, exactly as the provider creates them. */ +function writeCredentials(file: string, text: string): Promise { + return writeFile(file, text, { mode: 0o600 }) +} + const ALPHA = credentialRef('DSH_REVIEW_ALPHA') const BETA = credentialRef('DSH_REVIEW_BETA') const INNER = credentialRef('DSH_REVIEW_INNER') @@ -44,7 +49,7 @@ describe('read-modify-write', () => { await ctx.credentials.set(ALPHA, 'one') // The external edit has landed on disk but no watcher reported it (watch // is off — the same blind spot as a debounce window or a missed event). - await writeFile(path, `${ALPHA}: one\n${BETA}: external\n`) + await writeCredentials(path, `${ALPHA}: one\n${BETA}: external\n`) await ctx.credentials.set(ALPHA, 'two') const text = await readFile(path, 'utf8') expect(text).toContain(`${BETA}: external`) @@ -124,7 +129,7 @@ describe('document editor', () => { const dir = await tempDir() const path = join(dir, '.credentials.yaml') const wrapped = `DSH_REVIEW_WRAPPED: |-\n line1\n line2\n${ALPHA}: a\n` - await writeFile(path, wrapped) + await writeCredentials(path, wrapped) const ctx = await boot({ path, watch: false }) await ctx.credentials.set(ALPHA, 'b') expect(await readFile(path, 'utf8')).toBe(`DSH_REVIEW_WRAPPED: |-\n line1\n line2\n${ALPHA}: b\n`) diff --git a/packages/credentials/credentials-local/tests/watcher.spec.ts b/packages/credentials/credentials-local/tests/watcher.spec.ts index 8f34b09868..8c216e6976 100644 --- a/packages/credentials/credentials-local/tests/watcher.spec.ts +++ b/packages/credentials/credentials-local/tests/watcher.spec.ts @@ -6,6 +6,11 @@ import { join } from 'node:path' import { credentialRef } from '@deepseek-ai/dsh-credentials' import { CredentialsLocal } from '../src/index.ts' +/** Credential documents are seeded owner-only, exactly as the provider creates them. */ +function writeCredentials(file: string, text: string): Promise { + return writeFile(file, text, { mode: 0o600 }) +} + // chokidar is the nondeterministic OS boundary: faking it lets these tests // drive the event pipeline (error events, races with unreadable files) // deterministically. Real end-to-end watching stays covered by local.spec.ts. @@ -80,7 +85,7 @@ describe('watcher pipeline', () => { instance!.watcher.emit('error', new Error('watch backend failure')) expect(await ctx.credentials.resolve(KEY)).toBeUndefined() - await writeFile(path, 'DSH_CRED_PIPE: arrived\n') + await writeCredentials(path, 'DSH_CRED_PIPE: arrived\n') instance!.watcher.emit('all', 'change', path) await vi.waitFor(async () => { expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'arrived', source: 'file' }) @@ -90,7 +95,7 @@ describe('watcher pipeline', () => { it('keeps the last good snapshot when the file turns unreadable at runtime', async () => { const dir = await tempDir() const path = join(dir, '.credentials.yaml') - await writeFile(path, 'DSH_CRED_PIPE: good\n') + await writeCredentials(path, 'DSH_CRED_PIPE: good\n') const ctx = await boot({ path, debounceMs: 5 }) await chmod(path, 0o000) @@ -113,7 +118,7 @@ describe('watcher pipeline', () => { }) const [instance] = await fakeInstances() - await writeFile(path, 'DSH_CRED_PIPE: first\n') + await writeCredentials(path, 'DSH_CRED_PIPE: first\n') instance!.watcher.emit('all', 'change', path) // The snapshot commits before the fan-out, so the value lands even though // the listener threw out of the refresh. @@ -122,7 +127,7 @@ describe('watcher pipeline', () => { }) arm = false - await writeFile(path, 'DSH_CRED_PIPE: second\n') + await writeCredentials(path, 'DSH_CRED_PIPE: second\n') instance!.watcher.emit('all', 'change', path) await vi.waitFor(async () => { expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'second', source: 'file' }) @@ -132,7 +137,7 @@ describe('watcher pipeline', () => { it('quiesces the refresh pipeline before dispose completes', async () => { const dir = await tempDir() const path = join(dir, '.credentials.yaml') - await writeFile(path, 'DSH_CRED_PIPE: initial\n') + await writeCredentials(path, 'DSH_CRED_PIPE: initial\n') const ctx = new Context() const fiber = ctx.plugin(CredentialsLocal, { path, debounceMs: 5 }) await fiber @@ -142,7 +147,7 @@ describe('watcher pipeline', () => { if (disposed) postDisposeCommits += 1 }) - await writeFile(path, 'DSH_CRED_PIPE: changed\n') + await writeCredentials(path, 'DSH_CRED_PIPE: changed\n') const [instance] = await fakeInstances() // Two queued refreshes: dispose interrupts one mid-flight and the other // before it starts, so both closed guards must hold. @@ -159,7 +164,7 @@ describe('watcher pipeline', () => { it('empties the snapshot when the document is deleted and emits the removals', async () => { const dir = await tempDir() const path = join(dir, '.credentials.yaml') - await writeFile(path, 'DSH_CRED_PIPE: doomed\n') + await writeCredentials(path, 'DSH_CRED_PIPE: doomed\n') const ctx = await boot({ path, debounceMs: 5 }) const seen: string[] = [] ctx.on('credentials/updated', (ref) => { @@ -178,7 +183,7 @@ describe('watcher pipeline', () => { it('keeps the last good snapshot when an external edit makes the document invalid', async () => { const dir = await tempDir() const path = join(dir, '.credentials.yaml') - await writeFile(path, 'DSH_CRED_PIPE: a\n') + await writeCredentials(path, 'DSH_CRED_PIPE: a\n') const ctx = await boot({ path, debounceMs: 5 }) const seen: string[] = [] ctx.on('credentials/updated', (ref) => { @@ -189,7 +194,7 @@ describe('watcher pipeline', () => { // this document holds nothing but credentials. A live reload must warn // and keep serving the last good snapshot rather than take the process // down or silently drop the entry it could not validate. - await writeFile(path, 'BAD-KEY: 2\nDSH_CRED_PIPE: b\n') + await writeCredentials(path, 'BAD-KEY: 2\nDSH_CRED_PIPE: b\n') const [instance] = await fakeInstances() instance!.watcher.emit('all', 'change', path) await new Promise(resolve => setTimeout(resolve, 50)) @@ -197,7 +202,7 @@ describe('watcher pipeline', () => { expect(seen).toEqual([]) // Repairing the document resumes publishing. - await writeFile(path, 'DSH_CRED_PIPE: b\n') + await writeCredentials(path, 'DSH_CRED_PIPE: b\n') instance!.watcher.emit('all', 'change', path) await vi.waitFor(async () => { expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'b', source: 'file' }) @@ -218,11 +223,11 @@ describe('watcher pipeline', () => { it('reconciles at watcher ready so a change during setup is not missed', async () => { const dir = await tempDir() const path = join(dir, '.credentials.yaml') - await writeFile(path, `${KEY}: a\n`) + await writeCredentials(path, `${KEY}: a\n`) const ctx = await boot({ path, debounceMs: 5 }) // Written after the initial load but before the watcher became active: // no 'all' event will ever fire for it. - await writeFile(path, `${KEY}: written-before-ready\n`) + await writeCredentials(path, `${KEY}: written-before-ready\n`) const [instance] = await fakeInstances() instance!.watcher.emit('ready') await vi.waitFor(async () => { diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 85985c41d8..7ab5dd8dd2 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -47,12 +47,11 @@ export interface DeepSeekConnectionOptions { /** Endpoint base; `/chat/completions` is appended. */ baseURL: string /** - * Literal API key of this same resolution, when the configuration carried - * one. Travelling with the endpoint is the point: a request can never pair - * one generation's URL with another generation's secret. + * Credential reference of this same resolution, resolved per request. + * Travelling with the endpoint is the point: a request can never pair one + * generation's URL with another generation's secret. Configuration carries + * only this name — a literal key is not a configuration value. */ - apiKey?: string - /** Credential reference of this same resolution, resolved per request when no literal key exists. */ apiKeyEnv: CredentialRef /** Request defaults applied to every call (thinking mode, effort). */ defaults: RequestDefaults diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index effa080409..bdcdfc6006 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -59,8 +59,6 @@ const DEFAULT_MODELS: DeepSeekCatalogModel[] = [ * reasoning effort resolves to `high`. */ export interface Config { - /** 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 from a trusted environment layer, then the public API. */ @@ -89,7 +87,6 @@ const catalogModel: z = z.object({ }) export const Config: z = z.object({ - apiKey: z.string().role('secret'), apiKeyEnv: z.string().role('credential-ref').default(DEFAULT_API_KEY_ENV), baseURL: z.string(), thinking: z.union(['enabled', 'disabled']), @@ -147,9 +144,9 @@ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): Dee * load (fail loud) and for each settings snapshot at its first use. * @param config - raw plugin config or resolved settings snapshot. * @param environment - this run's environment layers, or `undefined` outside - * the product CLI. Only the launching shell and the user's own `.env` may - * supply an endpoint: a base URL decides where the resolved API key is sent, - * so a file inside the workspace must not be able to redirect it. + * the product CLI. Every layer may supply an endpoint: the product trusts the + * project it is launched in, so a checkout can point its own agent at the + * gateway that checkout is meant to use. * @returns validated connection facts plus the credential reference. */ export function resolveAdapterOptions(config: Config, environment?: EnvironmentSnapshot): ResolvedDeepSeekOptions { @@ -175,10 +172,9 @@ export function resolveAdapterOptions(config: Config, environment?: EnvironmentS ) } return { - ...config.apiKey !== undefined && config.apiKey.length > 0 ? { apiKey: config.apiKey } : {}, apiKeyEnv: credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV), baseURL: config.baseURL - ?? environment?.getFrom(BASE_URL_ENV, ['process', 'user-env'])?.value + ?? environment?.getFrom(BASE_URL_ENV, ['process', 'project-env', 'user-env'])?.value ?? PUBLIC_BASE_URL, defaults: { thinking: config.thinking, @@ -220,7 +216,6 @@ export function apply(ctx: Context, config: Config): void { 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) { @@ -228,16 +223,13 @@ export function apply(ctx: Context, config: Config): void { if (hit !== undefined) return hit.value } else { // Without the seam there is no managed store to rank against, so the - // launching environment is the whole credential plane — but only that - // layer: a key from a discovered project file would route this request - // through an account the launch never chose. - const inherited = environmentOf(ctx).getFrom(ref, ['process']) - if (inherited !== undefined && inherited.value.length > 0) return inherited.value + // environment is the whole credential plane. + const ambient = environmentOf(ctx).getFrom(ref, ['process', 'project-env', 'user-env']) + if (ambient !== undefined && ambient.value.length > 0) return ambient.value } throw new LlmError( `llm-deepseek: no API key for provider route "${PROVIDER}"; store ${ref} through the credentials` - + ` service (the web Models page writes it), export ${ref} in the launching environment, or — as a` - + ' last resort — set a literal "apiKey" in the llm-deepseek settings section', + + ` service (the web Models page writes it), or export ${ref} in the launching environment`, 'MISSING_CREDENTIAL', ) } diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index c9db376c8a..4f720ca1b8 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -13,7 +13,7 @@ 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, PUBLIC_BASE_URL, resolveAdapterOptions } 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' import { closeMockServers, mockServer, textEvents } from './mock-server.ts' @@ -26,9 +26,12 @@ afterEach(async () => { }) async function harness(baseURL: string, config: object = {}) { + // Configuration carries only the reference; the key comes from the + // environment, which is the whole credential plane without a mounted seam. + vi.stubEnv('DEEPSEEK_API_KEY', 'test-key') const ctx = new Context() await ctx.plugin(LlmService) - await ctx.plugin(LlmDeepSeek, { apiKey: 'test-key', baseURL, ...config }) + await ctx.plugin(LlmDeepSeek, { baseURL, ...config }) return ctx } @@ -567,7 +570,6 @@ describe('plugin registration and config', () => { const ctx = new Context() await ctx.plugin(LlmService) const fiber = await ctx.plugin(LlmDeepSeek, { - apiKey: 'k', baseURL: server.url, }) expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }]) @@ -586,7 +588,6 @@ describe('plugin registration and config', () => { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(LlmDeepSeek, { - apiKey: 'k', baseURL: 'http://127.0.0.1:1', retryPolicy: { mode: 'always', @@ -605,7 +606,7 @@ describe('plugin registration and config', () => { it('owns the deepseek provider and advertises the default models', async () => { const ctx = new Context() await ctx.plugin(LlmService) - await ctx.plugin(LlmDeepSeek, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' }) + await ctx.plugin(LlmDeepSeek, { baseURL: 'http://127.0.0.1:1' }) expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }]) await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([ { provider: 'deepseek-official', id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash' }, @@ -633,7 +634,6 @@ describe('plugin registration and config', () => { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(LlmDeepSeek, { - apiKey: 'k', baseURL: 'http://127.0.0.1:1', reasoningEffort: effort, }) @@ -654,7 +654,6 @@ describe('plugin registration and config', () => { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(LlmDeepSeek, { - apiKey: 'k', baseURL: 'http://127.0.0.1:1', thinking: 'disabled', reasoningEffort: 'off', @@ -674,7 +673,6 @@ describe('plugin registration and config', () => { const ctx = new Context() await ctx.plugin(LlmService) await expect(ctx.plugin(LlmDeepSeek, { - apiKey: 'k', baseURL: 'http://127.0.0.1:1', thinking: 'disabled', reasoningEffort, @@ -704,7 +702,7 @@ describe('plugin registration and config', () => { it('uses the default model catalog when apply is called directly', async () => { const ctx = new Context() await ctx.plugin(LlmService) - LlmDeepSeek.apply(ctx, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' }) + LlmDeepSeek.apply(ctx, { baseURL: 'http://127.0.0.1:1' }) await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([ { provider: 'deepseek-official', id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash' }, { provider: 'deepseek-official', id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro' }, @@ -715,7 +713,6 @@ describe('plugin registration and config', () => { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(LlmDeepSeek, { - apiKey: 'k', baseURL: 'http://127.0.0.1:1', models: [ { id: 'private-fast', contextWindow: 32_000 }, @@ -749,7 +746,6 @@ describe('plugin registration and config', () => { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(LlmDeepSeek, { - apiKey: 'k', baseURL: 'http://127.0.0.1:1', defaultContextWindow: 256_000, models: [ @@ -770,7 +766,6 @@ describe('plugin registration and config', () => { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(LlmDeepSeek, { - apiKey: 'k', baseURL: 'http://127.0.0.1:1', models: [], }) @@ -787,7 +782,6 @@ describe('plugin registration and config', () => { const ctx = new Context() await ctx.plugin(LlmService) await expect(ctx.plugin(LlmDeepSeek, { - apiKey: 'k', baseURL: 'http://127.0.0.1:1', models: [...models], })).rejects.toThrow(message) @@ -799,7 +793,6 @@ describe('plugin registration and config', () => { await ctx.plugin(LlmService) expect(() => { LlmDeepSeek.apply(ctx, { - apiKey: 'k', baseURL: 'http://127.0.0.1:1', models: [{ id: 'invalid-context', contextWindow: 0 }], }) @@ -816,7 +809,6 @@ describe('plugin registration and config', () => { const ctx = new Context() await ctx.plugin(LlmService) await expect(ctx.plugin(LlmDeepSeek, { - apiKey: 'k', baseURL: 'http://127.0.0.1:1', defaultContextWindow, })).rejects.toThrow(/defaultContextWindow/) @@ -833,7 +825,6 @@ describe('plugin registration and config', () => { const ctx = new Context() await ctx.plugin(LlmService) await expect(ctx.plugin(LlmDeepSeek, { - apiKey: 'k', baseURL: 'http://127.0.0.1:1', maxTokens, })).rejects.toThrow(/maxTokens/) @@ -864,7 +855,7 @@ describe('plugin registration and config', () => { // 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) + .rejects.toThrow(/store DEEPSEEK_API_KEY through the credentials service.*export DEEPSEEK_API_KEY/s) }) it('reads the ambient variable when no credentials seam is mounted', async () => { @@ -900,25 +891,26 @@ describe('plugin registration and config', () => { it('uses DEEPSEEK_BASE_URL when config omits baseURL', async () => { const server = await mockServer([{ kind: 'sse', events: textEvents }]) vi.stubEnv('DEEPSEEK_BASE_URL', server.url) + vi.stubEnv('DEEPSEEK_API_KEY', 'test-key') const ctx = new Context() await ctx.plugin(LlmService) - await ctx.plugin(LlmDeepSeek, { apiKey: 'k' }) + await ctx.plugin(LlmDeepSeek, {}) await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) expect(server.requests).toHaveLength(1) }) - it('takes DEEPSEEK_BASE_URL from the launching shell or the user .env, never from the project', () => { + it('takes DEEPSEEK_BASE_URL from any environment layer, with explicit config still on top', () => { const trusted = createEnvironmentSnapshot([ { source: 'user-env', path: '/home/.dsh/.env', values: { DEEPSEEK_BASE_URL: 'https://user.example' } }, ]) expect(resolveAdapterOptions({}, trusted).baseURL).toBe('https://user.example') - // A base URL decides where the resolved API key is sent, so a file inside - // a model-writable workspace must not be able to redirect it. + // The product trusts the project it is launched in, so a checkout can + // point its own agent at the gateway that checkout is meant to use. const project = createEnvironmentSnapshot([ - { source: 'project-env', path: '/work/.env', values: { DEEPSEEK_BASE_URL: 'https://attacker.example' } }, + { source: 'project-env', path: '/work/.env', values: { DEEPSEEK_BASE_URL: 'https://project.example' } }, ]) - expect(resolveAdapterOptions({}, project).baseURL).toBe(PUBLIC_BASE_URL) + expect(resolveAdapterOptions({}, project).baseURL).toBe('https://project.example') // An explicitly configured endpoint outranks every environment layer, so a // stale shell value cannot rewrite a deployment's own gateway. const shell = createEnvironmentSnapshot([ @@ -966,12 +958,10 @@ describe('plugin registration and config', () => { const ctx = new Context() await ctx.plugin(LlmService) await expect(ctx.plugin(LlmDeepSeek, { - apiKey: 'k', baseURL: 'http://127.0.0.1:1', streamIdleTimeoutMs: 0, })).rejects.toThrow(/streamIdleTimeoutMs/) await expect(ctx.plugin(LlmDeepSeek, { - apiKey: 'k', baseURL: 'http://127.0.0.1:1', streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1, })).rejects.toThrow(/streamIdleTimeoutMs/) @@ -982,7 +972,6 @@ describe('plugin registration and config', () => { await ctx.plugin(LlmService) await expect(ctx.plugin(LlmDeepSeek, { - apiKey: 'k', baseURL: 'http://127.0.0.1:1', retryPolicy: { mode: 'normal', maxRetries: -1 }, })).rejects.toThrow(/retryPolicy/) diff --git a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts index 6aecdcdaf7..153281afe3 100644 --- a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts @@ -61,7 +61,7 @@ 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, '.credentials.yaml'), 'DEEPSEEK_API_KEY: first-key\n') + await writeFile(join(dir, '.credentials.yaml'), 'DEEPSEEK_API_KEY: first-key\n', { mode: 0o600 }) const serverA = await mockServer([{ kind: 'sse', events: textEvents }]) const serverB = await mockServer([{ kind: 'sse', events: textEvents }]) const { ctx } = await boot(dir, { baseURL: serverA.url }) @@ -78,16 +78,21 @@ describe('request-level dynamic configuration', () => { expect(serverB.headers[0]?.authorization).toBe('Bearer second-key') }) - it('prefers a literal settings apiKey over the credential layers', async () => { + it('refuses a literal apiKey in settings and keeps serving the stored credential', async () => { vi.stubEnv('DEEPSEEK_API_KEY', '') const dir = await home() - await writeFile(join(dir, '.credentials.yaml'), 'DEEPSEEK_API_KEY: file-key\n') + await writeFile(join(dir, '.credentials.yaml'), 'DEEPSEEK_API_KEY: file-key\n', { mode: 0o600 }) const server = await mockServer([{ kind: 'sse', events: textEvents }]) const { ctx } = await boot(dir, { baseURL: server.url }) + // Configuration carries a reference, never a value. The namespace has no + // `apiKey` field, so writing one is dropped by the schema rather than + // rejected (no adapter namespace is strict); what matters is that a + // settings document cannot become a second credential store outranking + // `.credentials.yaml` and the environment. await ctx.settings.update(NS, { apiKey: 'literal-key' }) await prompt(ctx) - expect(server.headers[0]?.authorization).toBe('Bearer literal-key') + expect(server.headers[0]?.authorization).toBe('Bearer file-key') }) it('starts keyless and serves the next request once the key arrives', async () => { @@ -152,17 +157,16 @@ describe('request-level dynamic configuration', () => { ]) }) - it('sends the whole last-good snapshot when a rejected one changed both the key and the URL', async () => { - vi.stubEnv('DEEPSEEK_API_KEY', '') + it('keeps the whole last-good snapshot when a rejected one changed the URL', async () => { 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 }) + vi.stubEnv('DEEPSEEK_API_KEY', 'good-key') + const { ctx } = await boot(dir, { baseURL: good.url }) - // One snapshot moves the endpoint AND the literal key, and fails the - // resolve step beyond the schema (duplicate catalog ids). + // One snapshot moves the endpoint 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' }], }) @@ -178,7 +182,7 @@ describe('request-level dynamic configuration', () => { it('falls back to the composition entry when settings detach', async () => { vi.stubEnv('DEEPSEEK_API_KEY', '') const dir = await home() - await writeFile(join(dir, '.credentials.yaml'), 'DEEPSEEK_API_KEY: steady-key\n') + await writeFile(join(dir, '.credentials.yaml'), 'DEEPSEEK_API_KEY: steady-key\n', { mode: 0o600 }) 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 }) diff --git a/packages/llm/llm-deepseek/tests/loader-composition.spec.ts b/packages/llm/llm-deepseek/tests/loader-composition.spec.ts index c8d596af74..a7e1f433e5 100644 --- a/packages/llm/llm-deepseek/tests/loader-composition.spec.ts +++ b/packages/llm/llm-deepseek/tests/loader-composition.spec.ts @@ -51,7 +51,7 @@ async function loadComposition( const credentialsPath = join(root, '.credentials.yaml') if (options.withDynamic && fresh) { await writeFile(settingsPath, '# personal settings\n') - await writeFile(credentialsPath, 'DEEPSEEK_API_KEY: boot-key\n') + await writeFile(credentialsPath, 'DEEPSEEK_API_KEY: boot-key\n', { mode: 0o600 }) } const configPath = join(root, 'cordis.yml') @@ -76,7 +76,6 @@ async function loadComposition( " name: '@deepseek-ai/dsh-llm-deepseek'", ' config:', ` baseURL: ${JSON.stringify(options.baseURL)}`, - ...options.withDynamic ? [] : [' apiKey: entry-key'], '', ].join('\n')) @@ -122,7 +121,7 @@ describe('llm-deepseek real dynamic composition', () => { await vi.waitFor(() => { expect((ctx.get('settings')!.get(NS) as { baseURL?: string }).baseURL).toBe(serverB.url) }, { timeout: 5000 }) - await writeFile(credentialsPath, 'DEEPSEEK_API_KEY: rotated-key\n') + await writeFile(credentialsPath, 'DEEPSEEK_API_KEY: rotated-key\n', { mode: 0o600 }) await vi.waitFor(async () => { expect(await ctx.get('credentials')!.resolve(KEY_REF)).toEqual({ value: 'rotated-key', source: 'file' }) }, { timeout: 5000 }) @@ -161,8 +160,10 @@ describe('llm-deepseek real dynamic composition', () => { 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', '') + it('boots the same adapter on entry config alone, resolving the reference from the environment', async () => { + // No settings and no credentials provider: configuration carries only the + // reference, so the environment is the whole credential plane here. + vi.stubEnv('DEEPSEEK_API_KEY', 'entry-key') const server = await mockServer([{ kind: 'sse', events: textEvents }]) const { ctx } = await loadComposition({ withDynamic: false, baseURL: server.url }) diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index 862aa2afca..c138b8f5fc 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -100,9 +100,8 @@ export function apply(ctx: Context, config: Config): void { const credentials = ctx.get('credentials') const hit = credentials !== undefined ? (await credentials.resolve(ref))?.value - // Without the seam the launching environment is the whole credential - // plane — but only that layer, never a discovered project file. - : environmentOf(ctx).getFrom(ref, ['process'])?.value + // Without the seam the environment is the whole credential plane. + : environmentOf(ctx).getFrom(ref, ['process', 'project-env', 'user-env'])?.value 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` diff --git a/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts index 2c60ba0e83..cc5cd17e55 100644 --- a/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts @@ -53,7 +53,7 @@ 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, '.credentials.yaml'), 'PI_DYNAMIC_KEY: pk-from-settings\n') + await writeFile(join(dir, '.credentials.yaml'), 'PI_DYNAMIC_KEY: pk-from-settings\n', { mode: 0o600 }) const server = await mockServer([{ events: textEvents }]) // The exact product posture: `- id: llm-pi-ai` with no config at all. const ctx = await boot(dir, {}) @@ -112,7 +112,7 @@ describe('request-level dynamic profiles', () => { it('rotates the per-request credential referenced by apiKeyEnv', async () => { vi.stubEnv('PI_DYNAMIC_KEY', '') const dir = await home() - await writeFile(join(dir, '.credentials.yaml'), 'PI_DYNAMIC_KEY: pk-one\n') + await writeFile(join(dir, '.credentials.yaml'), 'PI_DYNAMIC_KEY: pk-one\n', { mode: 0o600 }) const server = await mockServer([{ events: textEvents }, { events: textEvents }]) const ctx = await boot(dir, { providers: { deepseek: { apiKeyEnv: 'PI_DYNAMIC_KEY', baseURL: server.url } }, diff --git a/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts b/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts index 5d32a748ea..d5eed60e5e 100644 --- a/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts +++ b/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts @@ -40,7 +40,7 @@ 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, '.credentials.yaml'), 'PI_COMPOSITION_KEY: key-from-store\n') + await writeFile(join(root, '.credentials.yaml'), 'PI_COMPOSITION_KEY: key-from-store\n', { mode: 0o600 }) const configPath = join(root, 'cordis.yml') await writeFile(configPath, [ diff --git a/packages/llm/llm-retry/tests/transport-recovery.spec.ts b/packages/llm/llm-retry/tests/transport-recovery.spec.ts index a17074504a..653bade210 100644 --- a/packages/llm/llm-retry/tests/transport-recovery.spec.ts +++ b/packages/llm/llm-retry/tests/transport-recovery.spec.ts @@ -1,7 +1,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { createServer } from 'node:http' import type { AddressInfo } from 'node:net' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' @@ -34,10 +34,10 @@ async function harness( baseURL: string, options: { streamIdleTimeoutMs?: number; initialDelayMs?: number } = {}, ): Promise { + vi.stubEnv('DEEPSEEK_API_KEY', 'mock-key') const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(LlmDeepSeek, { - apiKey: 'mock-key', baseURL, streamIdleTimeoutMs: options.streamIdleTimeoutMs ?? 1_000, retryPolicy: { diff --git a/packages/settings/settings-local/src/index.ts b/packages/settings/settings-local/src/index.ts index 6b14eccc8f..d713083c20 100644 --- a/packages/settings/settings-local/src/index.ts +++ b/packages/settings/settings-local/src/index.ts @@ -242,10 +242,16 @@ export class SettingsLocal extends Settings { private parse(text: string): Record { let root: unknown if (this.spec.format === 'yaml') { + // `prettyErrors` is on only for `linePos`; `error.message` is never + // used, because the parser quotes the offending source line and a + // settings document can hold a `role('secret')` value. const document = parseDocument(text, { prettyErrors: true }) if (document.errors.length > 0) { throw new Error(`settings-local: invalid document at ${this.spec.filename}: ${ - document.errors.map(error => error.message).join('; ')}`) + document.errors.map((error) => { + const at = error.linePos?.[0] + return `${error.code}${at === undefined ? '' : ` at line ${String(at.line)}, column ${String(at.col)}`}` + }).join('; ')}`) } root = document.toJS() ?? {} } else { diff --git a/packages/util/environment/README.i18n.yaml b/packages/util/environment/README.i18n.yaml index 9d251d1940..c7ad354478 100644 --- a/packages/util/environment/README.i18n.yaml +++ b/packages/util/environment/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/environment/README.md -README.md: f642aa715c87878b2eaab9f034fb18163a6fbd2e -README.zh.md: a095730dbc8c2a4e7dc8dc57dc2930685c8fb453 +README.md: 526c7263106962cdbc19ec58c00b06e58849a258 +README.zh.md: 203b8252d2e96235ec083481ccafda129902cd38 diff --git a/packages/util/environment/README.md b/packages/util/environment/README.md index f642aa715c..526c726310 100644 --- a/packages/util/environment/README.md +++ b/packages/util/environment/README.md @@ -7,7 +7,7 @@ This run's environment as one immutable snapshot that remembers **which layer su | Layer | Source id | What it is | |---|---|---| | Inherited process environment | `process` | What the launching shell, CI job, or container passed in — this run's explicit intent | -| `/.env` | `project-env` | Whatever the project directory happens to contain; a model working in that workspace can write it | +| `/.env` | `project-env` | The project the harness was launched in, which the product trusts to configure its own agent | | `$DSH_HOME/.env` | `user-env` | The user's own machine-level defaults | Values do also reach `process.env` — a user's `--config` tree and third-party libraries read it — but that flattened view is not the authority for anything the harness resolves. @@ -16,14 +16,14 @@ Values do also reach `process.env` — a user's `--config` tree and third-party `get(name)` searches every layer, most trusted first. `getFrom(name, sources)` searches only the layers the caller trusts. -**Omitting a layer is a refusal, not a demotion.** A base URL decides where a resolved API key is sent, so the LLM adapters ask for `['process', 'user-env']`: no future reordering can let a project file redirect a credential, because that layer is never consulted at all. +**Omitting a layer is a refusal, not a demotion** — a caller that must never accept a layer leaves it out of the list, so no future reordering can let it back in. The provider adapters name all three, because the product trusts the project it runs in; the mechanism exists for the decisions where that is not true. ```ts import type { Context } from 'cordis' import { environmentOf } from '@deepseek-ai/dsh-environment' declare const ctx: Context -const endpoint = environmentOf(ctx).getFrom('DEEPSEEK_BASE_URL', ['process', 'user-env'])?.value +const endpoint = environmentOf(ctx).getFrom('DEEPSEEK_BASE_URL', ['process', 'project-env', 'user-env'])?.value ``` `environmentOf(ctx)` returns the launcher's snapshot when the product CLI booted the tree, and otherwise the inherited environment as the only layer. That fallback does not weaken the rules: an SDK host or a bare `cordis.yml` discovered no files, so everything it has really is the environment it was launched with. @@ -32,11 +32,13 @@ const endpoint = environmentOf(ctx).getFrom('DEEPSEEK_BASE_URL', ['process', 'us `isBootstrapOnly(name)` names the variables only the inherited environment may set. The launcher rejects a `.env` that declares one, before applying anything. -A bootstrap variable decides **how a process launches** (`PATH`, `SHELL`, `NODE_OPTIONS`, `NODE_PATH`, `LD_PRELOAD`, `LD_LIBRARY_PATH`, `DYLD_*`), **where code or model-visible instructions load from** (the whole `DSH_*` namespace, `HOME`, `USERPROFILE`, `XDG_*`), or **how the network is reached and trusted** (`HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, `NO_PROXY`, `SSL_CERT_FILE`, `SSL_CERT_DIR`, `NODE_EXTRA_CA_CERTS`). Matching is case-insensitive, so `https_proxy` is not a bypass. +Trusting a project to configure the agent's work is not the same as letting it change the harness. A bootstrap variable decides **how a process launches** (`PATH`, `SHELL`, `NODE_OPTIONS`, `LD_PRELOAD`, `DYLD_*`), **what code a runtime executes before the program it was asked to run** (`BASH_ENV` and its per-language siblings — `PERL5OPT`, `PYTHONSTARTUP`, `RUBYOPT`, `JAVA_TOOL_OPTIONS` — plus the Git hook commands), **where model-visible instructions load from** (the whole `DSH_*` namespace, `HOME`, `XDG_*`), or **how the network is reached and trusted** (proxy and CA variables). Matching is case-insensitive, so `https_proxy` is not a bypass. + +These take effect with no user action, before any turn, outside the permission policy and the sandbox: `DSH_PERMISSION_MODE` would switch off the approvals that make trusting a project meaningful, and `BASH_ENV` runs a file of the project's choosing on every `bash -c` the bash tool issues. The whole `DSH_*` namespace is denied rather than an audited subset: the harness's own switches — the permission mode, the agents home, the bundled skill root — are exactly what a hostile project would want, and a switch added later must not become settable by forgetting to list it. ## Known Limitations and Deferred Work -- **The snapshot is not a subprocess boundary** — every layer is also materialized into `process.env`, so ordinary project variables still reach child processes under [`dsh-subprocess`](../../subprocess/subprocess/README.md)'s scrub. Bootstrap variables cannot come from a file at all, but a project `.env` can still set, say, `GIT_SSH_COMMAND` for the tools an agent runs. +- **The snapshot is not a subprocess boundary** — every layer is also materialized into `process.env`, so ordinary project variables reach child processes under [`dsh-subprocess`](../../subprocess/subprocess/README.md)'s scrub. That is intended for ordinary variables; the code-loading hooks that would abuse it are rejected at load instead, and the deny list is the thing to extend when a new runtime hook appears. - **No per-workspace layer** — the project layer is the *invoking* directory, fixed at launch. A workspace selected later in the Web UI contributes nothing, deliberately: following it would let a model's own workspace change the harness environment mid-session. diff --git a/packages/util/environment/README.zh.md b/packages/util/environment/README.zh.md index a095730dbc..203b8252d2 100644 --- a/packages/util/environment/README.zh.md +++ b/packages/util/environment/README.zh.md @@ -7,7 +7,7 @@ | 层 | 来源 id | 它是什么 | |---|---|---| | 继承的进程环境 | `process` | 启动 shell、CI 任务或容器传入的东西——本次运行的明确意图 | -| `/.env` | `project-env` | 项目目录里恰好有的东西;在该工作区里工作的模型可以写它 | +| `/.env` | `project-env` | harness 被启动于其中的项目;产品信任它配置自己的 agent | | `$DSH_HOME/.env` | `user-env` | 用户自己的机器级默认值 | 这些值同样会进入 `process.env`——用户自己的 `--config` 树和第三方库要读它——但那份压平的视图不是 harness 解析任何值的依据。 @@ -16,14 +16,14 @@ `get(name)` 按可信度从高到低搜索所有层。`getFrom(name, sources)` 只搜索调用方信任的层。 -**省略某一层是拒绝,不是降级。** base URL 决定已解析的 API key 被发往何处,因此 LLM 适配器请求的是 `['process', 'user-env']`:后续任何重新排序都无法让项目文件重定向凭据,因为那一层根本不会被查询。 +**省略某一层是拒绝,不是降级**——绝不能接受某一层的调用方直接不把它列进去,后续任何重新排序都无法让它回来。provider 适配器三层全列,因为产品信任它所运行的项目;该机制是为那些「并非如此」的决策准备的。 ```ts import type { Context } from 'cordis' import { environmentOf } from '@deepseek-ai/dsh-environment' declare const ctx: Context -const endpoint = environmentOf(ctx).getFrom('DEEPSEEK_BASE_URL', ['process', 'user-env'])?.value +const endpoint = environmentOf(ctx).getFrom('DEEPSEEK_BASE_URL', ['process', 'project-env', 'user-env'])?.value ``` 当产品 CLI(命令行界面)启动了这棵树时,`environmentOf(ctx)` 返回启动器的快照;否则返回只含继承环境的那一层。该回退并不削弱规则:SDK 宿主或裸 `cordis.yml` 从未发现过任何文件,因此它拥有的一切确实就是它被启动时的环境。 @@ -32,11 +32,13 @@ const endpoint = environmentOf(ctx).getFrom('DEEPSEEK_BASE_URL', ['process', 'us `isBootstrapOnly(name)` 给出只有继承环境才能设置的变量。启动器一旦发现某个 `.env` 声明了其中之一,就会在应用任何内容之前拒绝启动。 -bootstrap 变量决定**进程如何启动**(`PATH`、`SHELL`、`NODE_OPTIONS`、`NODE_PATH`、`LD_PRELOAD`、`LD_LIBRARY_PATH`、`DYLD_*`)、**代码或模型可见的指令从哪里加载**(整个 `DSH_*` 命名空间、`HOME`、`USERPROFILE`、`XDG_*`),或者**网络如何抵达与信任**(`HTTP_PROXY`、`HTTPS_PROXY`、`ALL_PROXY`、`NO_PROXY`、`SSL_CERT_FILE`、`SSL_CERT_DIR`、`NODE_EXTRA_CA_CERTS`)。匹配不区分大小写,因此 `https_proxy` 不是绕过手段。 +信任一个项目配置 agent 的工作,不等于让它改变 harness 本身。bootstrap 变量决定**进程如何启动**(`PATH`、`SHELL`、`NODE_OPTIONS`、`LD_PRELOAD`、`DYLD_*`)、**运行时在执行被要求运行的程序之前先执行哪些代码**(`BASH_ENV` 及其各语言同类——`PERL5OPT`、`PYTHONSTARTUP`、`RUBYOPT`、`JAVA_TOOL_OPTIONS`——以及 Git 的钩子命令)、**模型可见的指令从哪里加载**(整个 `DSH_*` 命名空间、`HOME`、`XDG_*`),或者**网络如何抵达与信任**(proxy 与 CA 变量)。匹配不区分大小写,因此 `https_proxy` 不是绕过手段。 + +这些变量无需任何用户动作、在任何一轮开始之前、且在权限策略与沙箱之外就生效:`DSH_PERMISSION_MODE` 会关掉让「信任项目」有意义的那道审批,而 `BASH_ENV` 会在 bash 工具发出的每一次 `bash -c` 上执行项目指定的文件。 整个 `DSH_*` 命名空间被拒绝,而不是只拒绝一份经过审查的子集:harness 自己的开关——权限模式、agents home、内置 skill(技能)根目录——恰恰是敌意项目最想要的,而后来新增的开关不能因为忘记登记就变得可设置。 ## Known Limitations and Deferred Work -- **快照不是子进程边界**:每一层同样会被物化进 `process.env`,因此普通的项目变量仍会按 [`dsh-subprocess`](../../subprocess/subprocess/README.md) 的清洗规则抵达子进程。bootstrap 变量完全不能来自文件,但项目 `.env` 仍可以为 agent 运行的工具设置诸如 `GIT_SSH_COMMAND` 之类的变量。 +- **快照不是子进程边界**:每一层同样会被物化进 `process.env`,因此项目里的普通变量会按 [`dsh-subprocess`](../../subprocess/subprocess/README.md) 的清洗规则抵达子进程。这对普通变量是有意为之;会滥用这一点的代码加载钩子改为在加载时拒绝,新的运行时钩子出现时该扩展的是那份拒绝清单。 - **没有按工作区划分的层**:项目层是*调用*目录,在启动时固定。之后在 Web UI 中选择的工作区不贡献任何内容,这是刻意的:跟随它等于让模型自己的工作区在会话中途改变 harness 的环境。 diff --git a/packages/util/environment/src/index.ts b/packages/util/environment/src/index.ts index 100a0fe9f0..6e27656805 100644 --- a/packages/util/environment/src/index.ts +++ b/packages/util/environment/src/index.ts @@ -146,29 +146,51 @@ const BOOTSTRAP_NAMES = new Set([ // Process launch and module resolution. 'PATH', 'HOME', 'USERPROFILE', 'SHELL', 'NODE_OPTIONS', 'NODE_PATH', 'NODE_EXTRA_CA_CERTS', - 'LD_PRELOAD', 'LD_LIBRARY_PATH', + 'LD_PRELOAD', 'LD_LIBRARY_PATH', 'LD_AUDIT', + // Interpreter start-up hooks: each of these makes a runtime execute a file + // of the setter's choosing on every invocation, before the program runs. + // `BASH_ENV` is the sharpest — the bash tool spawns `bash -c`, which sources + // it every time — but every runtime an agent shells out to has one. + 'BASH_ENV', 'ENV', 'SHELLOPTS', 'BASHOPTS', + 'PERL5OPT', 'PERL5LIB', 'PYTHONSTARTUP', 'PYTHONPATH', 'RUBYOPT', 'RUBYLIB', + 'JAVA_TOOL_OPTIONS', '_JAVA_OPTIONS', 'JDK_JAVA_OPTIONS', + // Version-control hooks that run a command on the setter's behalf. + 'GIT_SSH', 'GIT_SSH_COMMAND', 'GIT_EXTERNAL_DIFF', 'GIT_PAGER', 'GIT_EDITOR', + 'EDITOR', 'VISUAL', 'PAGER', // Network reach and trust. 'SSL_CERT_FILE', 'SSL_CERT_DIR', 'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY', + 'REQUESTS_CA_BUNDLE', 'CURL_CA_BUNDLE', ]) /** Name prefixes no discovered file may set. */ -const BOOTSTRAP_PREFIXES = ['DSH_', 'XDG_', 'DYLD_'] +const BOOTSTRAP_PREFIXES = ['DSH_', 'XDG_', 'DYLD_', 'BASH_FUNC_'] /** * Whether a variable may come only from the inherited process environment. * - * A bootstrap variable decides how a process launches (`PATH`, `NODE_OPTIONS`, - * `LD_PRELOAD`), where code or model-visible instructions load from (`DSH_*` - * covers the Harness home, the agents home, and the bundled skill root), or - * how the network is reached and trusted (proxy and CA variables). A file the - * harness merely finds — including one a model can write inside the workspace - * — must never set them, so they are rejected at load rather than ranked - * below another layer. + * The invoking project is trusted to *configure* the agent's work — its + * endpoints, its ordinary variables, even a credential. It is not trusted to + * change the harness itself, and that is what a bootstrap variable does: it + * decides how a process launches (`PATH`, `NODE_OPTIONS`, `LD_PRELOAD`), what + * code a runtime executes before the program it was asked to run (`BASH_ENV` + * and its per-language siblings, the Git hook commands), where model-visible + * instructions load from (`DSH_*` covers the Harness home, the agents home, + * and the bundled skill root), or how the network is reached and trusted + * (proxy and CA variables). * - * The whole `DSH_*` namespace is denied rather than an audited subset: the - * harness's own switches are exactly the ones a hostile project would want, - * and a new switch must not become settable by forgetting to list it. + * The distinction is that these take effect with no user action, before any + * turn, outside the permission policy and the sandbox — `DSH_PERMISSION_MODE` + * would switch off the approvals that make trusting a project meaningful at + * all, and `BASH_ENV` runs a file of the project's choosing on every single + * `bash -c` the tool issues. Trusting a project's code to run under the + * agent's policy is not the same as letting it rewrite that policy. + * + * They are therefore rejected at load rather than ranked below another layer: + * a user who wrote one into a file believes it applies, and silently ignoring + * it is its own failure. The whole `DSH_*` namespace is denied rather than an + * audited subset, because a switch added later must not become settable by + * being forgotten. * @param name - the variable name. * @returns true when only the inherited environment may supply it. */ diff --git a/packages/web/web-search-deepseek/README.i18n.yaml b/packages/web/web-search-deepseek/README.i18n.yaml index edc7b5d18b..41fb3ae35c 100644 --- a/packages/web/web-search-deepseek/README.i18n.yaml +++ b/packages/web/web-search-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/web/web-search-deepseek/README.md -README.md: 9046934de209ed0787efa50332e5be16bfdf55c6 -README.zh.md: 94e01daba69cecd2f5c3c6680979ee5fd66d7cdd +README.md: 95340314fe08d0963b899f4a1d704a98f85963a5 +README.zh.md: efd02af805faa96781654b4a4a0dd69a6b8ed3e4 diff --git a/packages/web/web-search-deepseek/README.md b/packages/web/web-search-deepseek/README.md index 9046934de2..95340314fe 100644 --- a/packages/web/web-search-deepseek/README.md +++ b/packages/web/web-search-deepseek/README.md @@ -20,7 +20,7 @@ It reuses the `DEEPSEEK_API_KEY` credential reference (no new secret) but **not* |---|---|---| | `apiKey` | omitted | Literal DeepSeek API key. Prefer `apiKeyEnv` so no secret enters configuration; a non-empty literal wins. | | `apiKeyEnv` | `DEEPSEEK_API_KEY` | Credential reference resolved for each search through `ctx.credentials`, or from the process environment when that seam is absent. A missing value fails the call as `WEB_PROVIDER_CREDENTIAL_MISSING`. | -| `baseURL` | `https://api.deepseek.com/anthropic/v1` | Anthropic-compatible endpoint base; `/messages` is appended. Use a separate env var such as `$DEEPSEEK_SEARCH_BASE_URL` when overriding it; do not reuse `$DEEPSEEK_BASE_URL`, which belongs to the chat-completions LLM adapter. An unparseable value makes the provider unavailable. | +| `baseURL` | `https://api.deepseek.com/anthropic/v1` | Anthropic-compatible endpoint base; `/messages` is appended. Falls back to `$DEEPSEEK_SEARCH_BASE_URL` from any environment layer; do not reuse `$DEEPSEEK_BASE_URL`, which belongs to the chat-completions LLM adapter. An unparseable value makes the provider unavailable. | | `model` | `deepseek-v4-flash` | Anthropic-format model name. | | `apiVersion` | `2023-06-01` | `anthropic-version` header value. | | `maxTokens` | `4096` | Positive-integer upper bound on generated tokens for the Messages request. | @@ -31,7 +31,7 @@ It reuses the `DEEPSEEK_API_KEY` credential reference (no new secret) but **not* name: '@deepseek-ai/dsh-web-search-deepseek' config: apiKeyEnv: DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_SEARCH_BASE_URL + baseURL: https://gateway.internal/anthropic/v1 ``` ## Mapping diff --git a/packages/web/web-search-deepseek/README.zh.md b/packages/web/web-search-deepseek/README.zh.md index 94e01daba6..efd02af805 100644 --- a/packages/web/web-search-deepseek/README.zh.md +++ b/packages/web/web-search-deepseek/README.zh.md @@ -20,7 +20,7 @@ Exa 和 Perplexity 提供专用搜索端点,DeepSeek 则没有。该提供方 |---|---|---| | `apiKey` | 未设置 | DeepSeek API 密钥字面值。优先使用 `apiKeyEnv`,避免密钥进入配置;非空字面值优先。 | | `apiKeyEnv` | `DEEPSEEK_API_KEY` | 每次搜索都会通过 `ctx.credentials` 解析该凭据引用;没有该 seam 时则从进程环境解析。值缺失时,调用以 `WEB_PROVIDER_CREDENTIAL_MISSING` 失败。 | -| `baseURL` | `https://api.deepseek.com/anthropic/v1` | Anthropic 兼容端点基址;追加 `/messages`。覆盖时使用 `$DEEPSEEK_SEARCH_BASE_URL` 等独立环境变量;禁止复用属于 chat-completions LLM 适配器的 `$DEEPSEEK_BASE_URL`。无法解析时提供方不可用。 | +| `baseURL` | `https://api.deepseek.com/anthropic/v1` | Anthropic 兼容端点基址;追加 `/messages`。缺省时回退到任一环境层中的 `$DEEPSEEK_SEARCH_BASE_URL`;禁止复用属于 chat-completions LLM 适配器的 `$DEEPSEEK_BASE_URL`。无法解析时提供方不可用。 | | `model` | `deepseek-v4-flash` | Anthropic 格式模型名称。 | | `apiVersion` | `2023-06-01` | `anthropic-version` 标头值。 | | `maxTokens` | `4096` | Messages 请求生成 token 的正整数上限。 | @@ -31,7 +31,7 @@ Exa 和 Perplexity 提供专用搜索端点,DeepSeek 则没有。该提供方 name: '@deepseek-ai/dsh-web-search-deepseek' config: apiKeyEnv: DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_SEARCH_BASE_URL + baseURL: https://gateway.internal/anthropic/v1 ``` ## 映射 diff --git a/packages/web/web-search-deepseek/src/index.ts b/packages/web/web-search-deepseek/src/index.ts index 3a7e1f65a9..60b5a64692 100644 --- a/packages/web/web-search-deepseek/src/index.ts +++ b/packages/web/web-search-deepseek/src/index.ts @@ -68,6 +68,14 @@ export const Config: z = z.object({ maxUses: z.number().step(1).min(1), }) +/** + * Environment variable naming this provider's endpoint. Deliberately distinct + * from `$DEEPSEEK_BASE_URL`, which belongs to the chat-completions adapter: + * search speaks the Anthropic-compatible Messages API, so one variable cannot + * serve both. + */ +const SEARCH_BASE_URL_ENV = 'DEEPSEEK_SEARCH_BASE_URL' + /** Register the DeepSeek search provider with `ctx.web`. */ export function apply(ctx: Context, config: Config): void { const maxTokens = config.maxTokens ?? DEEPSEEK_DEFAULT_MAX_TOKENS @@ -81,13 +89,14 @@ export function apply(ctx: Context, config: Config): void { resolveApiKey: async () => { const credentials = ctx.get('credentials') if (credentials !== undefined) return (await credentials.resolve(apiKeyEnv))?.value - // Without the seam the launching environment is the whole credential - // plane — but only that layer, never a discovered project file. - const inherited = environmentOf(ctx).getFrom(apiKeyEnv, ['process']) - return inherited !== undefined && inherited.value.length > 0 ? inherited.value : undefined + // Without the seam the environment is the whole credential plane. + const ambient = environmentOf(ctx).getFrom(apiKeyEnv, ['process', 'project-env', 'user-env']) + return ambient !== undefined && ambient.value.length > 0 ? ambient.value : undefined }, apiKeyEnv, - baseURL: config.baseURL ?? DEEPSEEK_DEFAULT_BASE_URL, + baseURL: config.baseURL + ?? environmentOf(ctx).getFrom(SEARCH_BASE_URL_ENV, ['process', 'project-env', 'user-env'])?.value + ?? DEEPSEEK_DEFAULT_BASE_URL, model: config.model ?? DEEPSEEK_DEFAULT_MODEL, apiVersion: config.apiVersion ?? DEEPSEEK_DEFAULT_API_VERSION, maxTokens, diff --git a/packages/web/web-search-exa/src/index.ts b/packages/web/web-search-exa/src/index.ts index 87a8e6572e..d5c8b938ac 100644 --- a/packages/web/web-search-exa/src/index.ts +++ b/packages/web/web-search-exa/src/index.ts @@ -59,10 +59,9 @@ export const Config: z = z.object({ /** Register the Exa search provider with `ctx.web`. */ export function apply(ctx: Context, config: Config): void { ctx.web.registerSearchProvider(new ExaSearchProvider({ - // Only the launching shell and the user's own `.env` may name this key: - // a project directory can be written by the model, and a substituted key - // would route every request through an account someone else reads. - apiKey: config.apiKey ?? environmentOf(ctx).getFrom('EXA_API_KEY', ['process', 'user-env'])?.value ?? '', + // Every environment layer may name this key: the product trusts the + // project it is launched in, and the managed store is not involved here. + apiKey: config.apiKey ?? environmentOf(ctx).getFrom('EXA_API_KEY', ['process', 'project-env', 'user-env'])?.value ?? '', baseURL: config.baseURL ?? EXA_DEFAULT_BASE_URL, searchType: config.searchType ?? EXA_DEFAULT_SEARCH_TYPE, highlightsPerResult: config.highlightsPerResult ?? EXA_DEFAULT_HIGHLIGHTS_PER_RESULT, diff --git a/packages/web/web-search-perplexity/src/index.ts b/packages/web/web-search-perplexity/src/index.ts index b2b5804a92..c8088a3c23 100644 --- a/packages/web/web-search-perplexity/src/index.ts +++ b/packages/web/web-search-perplexity/src/index.ts @@ -53,10 +53,9 @@ export const Config: z = z.object({ /** Register the Perplexity search provider with `ctx.web`. */ export function apply(ctx: Context, config: Config): void { ctx.web.registerSearchProvider(new PerplexitySearchProvider({ - // Only the launching shell and the user's own `.env` may name this key: - // a project directory can be written by the model, and a substituted key - // would route every request through an account someone else reads. - apiKey: config.apiKey ?? environmentOf(ctx).getFrom('PERPLEXITY_API_KEY', ['process', 'user-env'])?.value ?? '', + // Every environment layer may name this key: the product trusts the + // project it is launched in, and the managed store is not involved here. + apiKey: config.apiKey ?? environmentOf(ctx).getFrom('PERPLEXITY_API_KEY', ['process', 'project-env', 'user-env'])?.value ?? '', baseURL: config.baseURL ?? PERPLEXITY_DEFAULT_BASE_URL, model: config.model ?? PERPLEXITY_DEFAULT_MODEL, maxTokens: config.maxTokens ?? PERPLEXITY_DEFAULT_MAX_TOKENS, From 590b76a7f018d61a13c89155904bb6e4fc4e8df1 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 5 Aug 2026 11:18:06 +0800 Subject: [PATCH 06/27] fix(config): close the review findings on configuration source ownership MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two had real security consequences: The bootstrap rejection ran on npm dotenv's parser while process.loadEnvFile applied the file with Node's own. Two independently maintained dialects meant the check and the thing it guards could disagree: a name Node accepts but the checker misses would reach process.env unchecked, and BASH_ENV there runs a file of the project's choosing on every `bash -c` the bash tool issues. Parse once with node:util's parseEnv — the same engine loadEnvFile uses — and assign the entries already checked, which also drops the dotenv dependency. llm-pi-ai still returned a literal profile.apiKey ahead of everything, and it registers a settings namespace, so the defect removed from llm-deepseek survived intact in its design twin. The field is gone from the profile schema, the resolution path, and the tests. The rest are consistency and documentation defects the review named: - verify-config-source-ownership did not scan the Python runtime's bundled cordis.yml, which still inlined apiKey and baseURL. Both are covered now, and the line-anchored INLINE_DENY documents that it is a tripwire, not a parser. - The deny list missed NODE_TLS_REJECT_UNAUTHORIZED, the askpass hooks, the GIT_CONFIG_* redirections, and PYTHONHOME — all implied by its own stated rule about what a variable does. - Snapshot lookups folded case on Windows, where environment names are case-insensitive and an exact-match Map could miss a higher-ranked layer. - The credentials note claimed a read-time permission check was "not taken" while this PR implemented it; the credentials-local README still described two layers, live process.env reads, dotenv-era limitations, and a renamed anchor; the llm-deepseek README still advertised the removed literal apiKey; and web.ts and base.cordis.yml kept personal-overlay wording. - The ownership note's literal-apiKey claim now names its scope: the web-search providers keep a literal field but register no settings namespace, so nothing can shadow a stored credential through them. --- ...4-configuration-source-ownership.i18n.yaml | 4 +- ...26-08-04-configuration-source-ownership.md | 2 +- ...08-04-configuration-source-ownership.zh.md | 2 +- ...-yaml-and-user-environment-layer.i18n.yaml | 4 +- ...entials-yaml-and-user-environment-layer.md | 2 +- ...ials-yaml-and-user-environment-layer.zh.md | 2 +- THIRD_PARTY_NOTICES.md | 1 - apps/cli/config/base.cordis.yml | 5 ++- apps/cli/src/web.ts | 2 +- docs/config-catalog.md | 4 +- .../credentials.i18n.yaml | 4 +- docs/core-data-structures/credentials.md | 2 +- docs/core-data-structures/credentials.zh.md | 2 +- .../credentials-local/README.i18n.yaml | 4 +- .../credentials/credentials-local/README.md | 24 +++++++---- .../credentials-local/README.zh.md | 24 +++++++---- .../credentials-local/src/index.ts | 6 +-- packages/credentials/credentials/src/index.ts | 2 +- packages/llm/llm-deepseek/README.i18n.yaml | 4 +- packages/llm/llm-deepseek/README.md | 4 +- packages/llm/llm-deepseek/README.zh.md | 6 +-- .../llm-deepseek/tests/dynamic-config.spec.ts | 6 +-- packages/llm/llm-pi-ai/src/config.ts | 6 --- packages/llm/llm-pi-ai/src/index.ts | 1 - packages/llm/llm-pi-ai/tests/adapter.spec.ts | 40 ++++++++++++------- .../llm-pi-ai/tests/dynamic-config.spec.ts | 26 +++++++++--- .../llm/llm-pi-ai/tests/sdk-options.spec.ts | 2 +- packages/ui/app-boot/package.json | 1 - packages/ui/app-boot/src/index.ts | 32 +++++++++++---- packages/util/environment/src/index.ts | 32 +++++++++++++-- pnpm-lock.yaml | 9 ----- .../runtime/cordis.yml | 9 ++--- scripts/verify-config-source-ownership.ts | 16 +++++++- 33 files changed, 180 insertions(+), 110 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml index 0bc04dc2bb..cbce8a65e8 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.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 .agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md -2026-08-04-configuration-source-ownership.md: 101c0e6ba4954b3fbb418b775322a9fd92c46a8c -2026-08-04-configuration-source-ownership.zh.md: ad59f9a96e144dd5078898da57195a8bb6897451 +2026-08-04-configuration-source-ownership.md: 97daf3c430ba09c000eab947e159030568a7f89d +2026-08-04-configuration-source-ownership.zh.md: 424c47d36f47136669f4e02f980e63cabd203f9c diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md index 101c0e6ba4..97daf3c430 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md @@ -56,7 +56,7 @@ The line is that these take effect with no user action, before any turn, outside - A `.env` holding `DSH_*`, `PATH`, or a proxy variable fails the launch instead of being applied. Developers keeping switches in a repository `.env` move them to their shell — a deliberate, loud break. - `--config` is no longer overridable by a stale shell endpoint. It is still overridable by a user's stored `settings.yaml`, which is the settings seam's layering and not something this note changes; a deployment that must win against stored settings uses `--config-replace`. - Not solved: the layers are still materialized into `process.env`, so ordinary project variables continue to reach child processes under the subprocess scrub. Bootstrap variables cannot come from a file at all, which closes the escalation path; a project `.env` setting something like `GIT_SSH_COMMAND` for the tools an agent runs remains possible and is recorded as a limitation on the package. -- The adapters no longer accept a literal `apiKey`: configuration carries the reference and nothing else, so a settings document cannot become a second credential store. No adapter namespace is strict, so writing one is dropped rather than rejected. +- The LLM adapters no longer accept a literal `apiKey`: configuration carries the reference and nothing else, so a settings document cannot become a second credential store. No adapter namespace is strict, so writing one is dropped rather than rejected. The web-search providers still declare a `role('secret')` literal key; they register no settings namespace, so nothing can shadow a stored credential through them, but the claim is about the adapters rather than the repository as a whole. - Exa and Perplexity still capture their key at load time rather than through the credential seam. They no longer read raw `process.env` — they resolve through the trusted layers — but converting them to per-request seam resolution is separate work. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md index ad59f9a96e..424c47d36f 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md @@ -58,7 +58,7 @@ inherited process environment (read-only, wins) - 含 `DSH_*`、`PATH` 或 proxy 变量的 `.env` 会导致启动失败而不是被应用。把开关放在仓库 `.env` 里的开发者需要改放到 shell——这是一次刻意且响亮的破坏。 - `--config` 不再会被陈旧的 shell endpoint 覆盖。但它仍然会被用户已存的 `settings.yaml` 覆盖,这是 settings seam 的分层方式,本 Note 不改变它;需要压过已存 settings 的部署方应使用 `--config-replace`。 - 未解决的:各层仍然会被物化进 `process.env`,因此普通项目变量继续按子进程清洗规则抵达子进程。bootstrap 变量完全不能来自文件,提权路径已封闭;项目 `.env` 为 agent 运行的工具设置诸如 `GIT_SSH_COMMAND` 之类的变量仍然可能,已作为限制记录在该包上。 -- 适配器不再接受字面 `apiKey`:配置只携带引用,因此 settings 文档无法成为第二个凭据存储。由于没有任何适配器 namespace 是 strict 的,写入该键会被 schema 丢弃而不是报错。 +- LLM 适配器不再接受字面 `apiKey`:配置只携带引用,因此 settings 文档无法成为第二个凭据存储。由于没有任何适配器 namespace 是 strict 的,写入该键会被 schema 丢弃而不是报错。web-search 提供方仍声明 `role('secret')` 的字面密钥字段;它们不注册 settings namespace,因此无法借此遮蔽已存凭据,但这条声明的范围是适配器,而不是整个仓库。 - Exa 与 Perplexity 仍在加载时捕获密钥,而不是经凭据 seam。它们不再读裸 `process.env`——改为经受信层解析——但把它们改造成按请求经 seam 解析是另一件事。 ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.i18n.yaml index eb74fbd0e2..376838d151 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.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 .agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.md -2026-08-04-credentials-yaml-and-user-environment-layer.md: f1bca69820d03fe67849bd7c7159489ac27cd2e0 -2026-08-04-credentials-yaml-and-user-environment-layer.zh.md: 7e6714abd33baad1fb2a570514754b467fcf8bd5 +2026-08-04-credentials-yaml-and-user-environment-layer.md: f03f3f885c13476619ba3cda51e2dfed7e3258c1 +2026-08-04-credentials-yaml-and-user-environment-layer.zh.md: 7cce1daeffadb18678f00a5c9acd1b14c6ac1b22 diff --git a/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.md b/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.md index f1bca69820..f03f3f885c 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.md +++ b/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.md @@ -34,7 +34,7 @@ There is no migration. The product is unreleased, and a key already in `$DSH_HOM - Given up: a key left in `$DSH_HOME/.env` is now hoisted into `process.env`, so it reaches subprocesses under the [subprocess credential scrub](../../../../packages/subprocess/subprocess/README.md) rather than staying inside the provider. That is the honest meaning of "ordinary environment layer"; a secret the Harness should own and isolate belongs in `.credentials.yaml`, which is never hoisted. - Given up: the same key shadows `.credentials.yaml` and makes the web Models page's write reject. The seam already reports `source: 'env', writable: false` for that state, and the rejection message now names the loaded `.env` as a place to unset it. - Bought: a non-secret in the user's `.env` finally takes effect, which was the original defect; the document format can reject what it cannot serve; and `0600` covers a file that holds only secrets instead of a file users are told to put ordinary configuration in. -- Not taken: a read-time permission check that fails startup when `.credentials.yaml` is more permissive than `0600`. Creation and atomic replacement already pin the mode; making a hand-created file fatal is a separable security decision. +- The `0600` the provider writes is also enforced on what it reads: on POSIX, a document with any group or other permission bit fails the launch before its contents are read, at boot and on every reload, and the diagnostic names the `chmod 600` repair. Windows has no mode to inspect — its ACLs are not expressible here — so the check is skipped rather than faked. - The `0600` boundary still stops other OS users and not the model, unchanged by this split — the [provider README](../../../../packages/credentials/credentials-local/README.md) owns that limit and the keychain-provider deferral. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.zh.md b/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.zh.md index 7e6714abd3..7cce1daeff 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.zh.md @@ -34,7 +34,7 @@ OPENAI_API_KEY: sk-… - 放弃的:留在 `$DSH_HOME/.env` 里的密钥现在会被提升进 `process.env`,因而会按[子进程凭据清洗](../../../../packages/subprocess/subprocess/README.md)的规则抵达子进程,而不再留在 provider 内部。这就是「普通环境层」的诚实含义;需要由 Harness 拥有并隔离的密钥属于 `.credentials.yaml`,后者永不提升。 - 放弃的:同一个键会遮蔽 `.credentials.yaml`,并让 Web Models 页的写入被拒。seam 对这种状态本来就报告 `source: 'env', writable: false`,而拒绝信息现在会把已加载的 `.env` 一并指为需要清除的位置。 - 换来的:用户 `.env` 里的非密钥值终于生效,这正是最初的缺陷;文档格式可以拒绝它无法承担的内容;`0600` 保护的是一个只存密钥的文件,而不是一个我们同时叫用户往里写普通配置的文件。 -- 未采纳的:在读取时校验权限、并在 `.credentials.yaml` 宽于 `0600` 时让启动失败。创建与原子替换已经钉住了模式;让手工创建的文件直接致命是一个可分离的安全决策。 +- provider 写入时用的 `0600` 同样约束它读取的内容:在 POSIX 上,只要文档带有任何 group 或 other 权限位,就会在读取内容之前让启动失败——启动时与每次 reload 都检查,诊断里给出 `chmod 600` 的修复命令。Windows 没有可检查的 mode(其 ACL 无法在此表达),因此跳过该检查而不是伪造它。 - `0600` 这条边界仍然只挡其他 OS 用户、挡不住模型,本次拆分未改变这一点——该限制及 keychain provider 的延后项归 [provider README](../../../../packages/credentials/credentials-local/README.md) 所有。 ## Alternatives considered diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 8cd2964da6..ca83c91965 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -52,7 +52,6 @@ External packages that a workspace package resolves at runtime. `scripts/install | [`clsx`](https://github.com/lukeed/clsx) | MIT | | [`commander`](https://github.com/tj/commander.js) | MIT | | [`diff`](https://github.com/kpdecker/jsdiff) | BSD-3-Clause | -| [`dotenv`](https://github.com/motdotla/dotenv) | BSD-2-Clause | | [`eventsource-parser`](https://github.com/rexxars/eventsource-parser) | MIT | | [`handlebars`](https://github.com/handlebars-lang/handlebars.js) | MIT | | [`immer`](https://github.com/immerjs/immer) | MIT | diff --git a/apps/cli/config/base.cordis.yml b/apps/cli/config/base.cordis.yml index 213841f58d..421a831362 100644 --- a/apps/cli/config/base.cordis.yml +++ b/apps/cli/config/base.cordis.yml @@ -22,8 +22,9 @@ # A `--config` overlay replaces this row's config to select exact GitHub # repository Plugin generations. The app registers the DSH-owned runtime even -# when the list is empty so a later personal-config edit can load -# transactionally; one-shot headless runs consume the startup value only. +# when the list is empty, so a `--config` overlay that supplies repositories +# needs no composition change here. Every surface reads that overlay once at +# startup. - id: repository-plugins name: '@deepseek-ai/dsh-repository-plugin' diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index ab4f195423..0265e3fe6a 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -95,7 +95,7 @@ export function prepareWebRuntimeContext(ctx: Context, sourceRoot: string, mode: * @param trustedHosts - extra authorities for the /api browser-trust fence, or `undefined` for the derived LAN literals alone. * @param config - an overlay of loader patches applied over the shipped web * composition, or `undefined` to boot the - * personal overlay; already parsed from `--config`. + * shipped Web composition; already parsed from `--config`. */ export async function runWeb( environment: EnvironmentSnapshot, diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 3646f58fb7..0d5c71648f 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -682,8 +682,6 @@ export interface Config { /** Configuration for one pi-ai provider route; the `providers` dict key IS the route. */ export interface PiAiProviderProfile { - /** Literal provider credential; prefer {@link apiKeyEnv}. With both absent pi-ai uses its provider-native ambient discovery. */ - 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. */ @@ -711,7 +709,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:62`](../packages/llm/llm-pi-ai/src/config.ts) +Source: [`packages/llm/llm-pi-ai/src/config.ts:60`](../packages/llm/llm-pi-ai/src/config.ts) ## `@deepseek-ai/dsh-llm-replay` diff --git a/docs/core-data-structures/credentials.i18n.yaml b/docs/core-data-structures/credentials.i18n.yaml index 23bb940afe..d44275d97e 100644 --- a/docs/core-data-structures/credentials.i18n.yaml +++ b/docs/core-data-structures/credentials.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/credentials.md -credentials.md: 3f6fcd127d01e2c49e17c70c002bebe9f363e951 -credentials.zh.md: b5d2d9e164a85ce090790635c438b768cae4c9ca +credentials.md: ef74ddeb4346e18f8d5d33488657e5d50f1d754e +credentials.zh.md: 09cf374a2346fd93aa834e3372321e6eeece6ed8 diff --git a/docs/core-data-structures/credentials.md b/docs/core-data-structures/credentials.md index 3f6fcd127d..ef74ddeb43 100644 --- a/docs/core-data-structures/credentials.md +++ b/docs/core-data-structures/credentials.md @@ -24,7 +24,7 @@ type CredentialRef = Branded<'CredentialRef'> interface ResolvedCredential { /** The non-empty secret value. */ value: string - /** Provider-defined source layer id (the local provider uses `env` and `file`). */ + /** Provider-defined source layer id (the local provider uses `env`, `file`, `project-env`, and `user-env`). */ source: string } ``` diff --git a/docs/core-data-structures/credentials.zh.md b/docs/core-data-structures/credentials.zh.md index b5d2d9e164..09cf374a23 100644 --- a/docs/core-data-structures/credentials.zh.md +++ b/docs/core-data-structures/credentials.zh.md @@ -24,7 +24,7 @@ type CredentialRef = Branded<'CredentialRef'> interface ResolvedCredential { /** The non-empty secret value. */ value: string - /** Provider-defined source layer id (the local provider uses `env` and `file`). */ + /** Provider-defined source layer id (the local provider uses `env`, `file`, `project-env`, and `user-env`). */ source: string } ``` diff --git a/packages/credentials/credentials-local/README.i18n.yaml b/packages/credentials/credentials-local/README.i18n.yaml index fc89d359e8..729ae6f958 100644 --- a/packages/credentials/credentials-local/README.i18n.yaml +++ b/packages/credentials/credentials-local/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/credentials/credentials-local/README.md -README.md: ca2af9d8a514b43aeef19abec7cda4e44645bdaf -README.zh.md: a8be53629853fe6fb7c39ef2281ac798b5624010 +README.md: 45c18714c9ca81d98d2c18c385c772545e2e15d1 +README.zh.md: 59e0158980cede3eb3f5590b00f858ddffcee328 diff --git a/packages/credentials/credentials-local/README.md b/packages/credentials/credentials-local/README.md index ca2af9d8a5..45c18714c9 100644 --- a/packages/credentials/credentials-local/README.md +++ b/packages/credentials/credentials-local/README.md @@ -2,14 +2,20 @@ English | [中文](README.zh.md) -File-backed [credentials](../credentials/README.md) provider: two layers, one honest precedence. +File-backed [credentials](../credentials/README.md) provider: four layers, one honest precedence. | Layer | Source id | Writable | Wins | |---|---|---|---| -| Live process environment | `env` | no | always | -| `$DSH_HOME/.credentials.yaml` document | `file` | yes (`set`/`unset`) | otherwise | +| Inherited process environment | `env` | no | always | +| `$DSH_HOME/.credentials.yaml` document | `file` | yes (`set`/`unset`) | over both `.env` layers | +| `/.env` | `project-env` | not here | over the user `.env` | +| `$DSH_HOME/.env` | `user-env` | not here | otherwise | -The environment wins because a launch-time override (`DEEPSEEK_API_KEY=… dsh`, CI secrets, a dev shell sourcing the repo `.env`) is operator intent for this run — and because it cannot be edited from inside, it must be *visibly* read-only: `describe()` reports `source: 'env', writable: false`, and `set`/`unset` reject instead of writing a change the reader would never see. Resolution reads `process.env` live and never writes it back. +The launching environment wins because a per-run override (`DEEPSEEK_API_KEY=… dsh`, a CI secret, a container `-e`) is operator intent for this run — and because it cannot be edited from inside, it must be *visibly* read-only: `describe()` reports `source: 'env', writable: false`, and `set`/`unset` reject instead of writing a change the reader would never see. + +Everything below it loses to the managed store, so a key written by the web page or TUI takes effect immediately even when an older key sits in a `.env`. Those two layers still resolve when nothing is stored, and `describe()` names them `project-env` or `user-env` with `writable: true` — storing a key replaces them as the effective source. + +Under the product CLI, resolution reads the launcher's frozen [environment snapshot](../../util/environment/README.md) rather than `process.env`: only the snapshot can say whether a value came from the launching shell or from a file. A composition the product CLI did not boot has the inherited environment as its only layer, which keeps embedders on the semantics they already had. ## Config @@ -35,13 +41,17 @@ Writes patch the parsed document rather than rebuilding it, so comments and the Any string value round-trips, multi-line values included, so no entry is unwritable for want of a quoting style. An empty stored value is absent, per the seam rule — which is why an empty string in the document is rejected outright: `unset` removes a key, it does not blank it. +## Permissions + +The provider creates the directory `0700` and creates or atomically replaces the document `0600`. It holds what it *reads* to that same bound: on POSIX a document carrying any group or other permission bit fails before its contents are parsed — at boot and on every reload — and the error names the `chmod 600` repair. Windows has no mode to inspect, so the check is skipped there rather than faked. + ## Hot reload External edits publish `credentials/updated` per changed reference after the snapshot is replaced **wholesale** — an entry deleted on disk never lingers in memory. The provider's own writes are recognized by content and publish exactly their one commit event. An unreadable or invalid document at runtime keeps the last good snapshot and warns; an absent file is an empty store; an unreadable or invalid file at boot fails loud. ## Security boundary -The document is `0600` under a `0700` directory, which stops other OS users — **not** the model. Tool processes (bash, the filesystem tools) run as the same user, and the shipped `workspace-write` file policy confines mutations rather than reads, so they can read this file exactly like any other file the user owns; no sandbox mode singles it out. What the harness does hold to is narrower: it never hands the model a resolved path to the document, and never loads it into the process environment — unlike `$DSH_HOME/.env`, which is the user's ordinary environment layer (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. +The document is `0600` under a `0700` directory, which stops other OS users — **not** the model. Tool processes (bash, the filesystem tools) run as the same user, and the shipped `workspace-write` file policy confines mutations rather than reads, so they can read this file exactly like any other file the user owns; no sandbox mode singles it out. What the harness does hold to is narrower: it never hands the model a resolved path to the document, and never loads it into the process environment — unlike `$DSH_HOME/.env`, which is the user's ordinary environment layer (see [app-boot's Harness home](../../ui/app-boot/README.md#the-harness-home)) — 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. @@ -55,9 +65,7 @@ No direct invalidation; credentials never enter a request prefix. ## Known Limitations and Deferred Work -- **Multi-line entries refuse `set`/`unset`** — the line editor will not rewrite an entry it would corrupt; `describe` reports them `writable: false` and edits must go to the file directly. - **Same-reference concurrent writes are last-write-wins** — the writer lock and the read-modify-write keep concurrent writers from dropping each other's entries, but two writers editing one reference still resolve to the later write; there is no revision check. - **A same-UID process can read the document** — see [Security boundary](#security-boundary): the file-effect sandbox modes do not deny reads, and an OS-keychain provider is deferred. -- **Unrepresentable values fail loud** — control characters, or a mix of both quote styles with backslashes, cannot round-trip the dotenv line format. -- **Environment changes are invisible** — `process.env` is read live per resolution, but no event can announce a change there. +- **Environment changes are invisible** — the snapshot is frozen at launch, so a variable exported after startup reaches neither resolution nor `describe`; changing an environment-sourced credential takes a restart. - **Atomic, not crash-durable** — inherited from `dsh-atomic-write`; the store re-reads on boot. diff --git a/packages/credentials/credentials-local/README.zh.md b/packages/credentials/credentials-local/README.zh.md index a8be536298..59e0158980 100644 --- a/packages/credentials/credentials-local/README.zh.md +++ b/packages/credentials/credentials-local/README.zh.md @@ -2,14 +2,20 @@ [English](README.md) | 中文 -文件型[凭据](../credentials/README.md) provider:两层来源,一条诚实的优先级。 +文件型[凭据](../credentials/README.md) provider:四层来源,一条诚实的优先级。 | 层 | 来源 id | 可写 | 优先 | |---|---|---|---| -| 活跃进程环境 | `env` | 否 | 恒定优先 | -| `$DSH_HOME/.credentials.yaml` 文档 | `file` | 是(`set`/`unset`) | 其余情况 | +| 继承的进程环境 | `env` | 否 | 恒定优先 | +| `$DSH_HOME/.credentials.yaml` 文档 | `file` | 是(`set`/`unset`) | 高于两个 `.env` 层 | +| `/.env` | `project-env` | 不在此处 | 高于用户 `.env` | +| `$DSH_HOME/.env` | `user-env` | 不在此处 | 其余情况 | -环境优先,因为启动时覆盖(`DEEPSEEK_API_KEY=… dsh`、CI 机密、加载了仓库 `.env` 的开发 shell)代表本次运行的操作者意图——而它无法从进程内部修改,就必须*可见地*只读:`describe()` 报告 `source: 'env', writable: false`,`set`/`unset` 直接拒绝,而不是写下一个读取方永远看不到的变更。解析实时读取 `process.env`,绝不写回。 +启动环境优先,因为按次覆盖(`DEEPSEEK_API_KEY=… dsh`、CI 机密、容器 `-e`)代表本次运行的操作者意图——而它无法从进程内部修改,就必须*可见地*只读:`describe()` 报告 `source: 'env', writable: false`,`set`/`unset` 直接拒绝,而不是写下一个读取方永远看不到的变更。 + +它之下的一切都输给受管存储,因此 Web 页面或 TUI 写入的密钥会立即生效,即使某个 `.env` 里还留着更旧的密钥。没有存储任何东西时这两层仍会解析,`describe()` 会把来源报告为 `project-env` 或 `user-env` 且 `writable: true`——存入一个密钥就会取代它们成为生效来源。 + +在产品 CLI(命令行界面)下,解析读取的是启动器冻结的[环境快照](../../util/environment/README.md)而不是 `process.env`:只有快照才说得清某个值来自启动 shell 还是来自某个文件。并非由产品 CLI 启动的组合只有继承环境这一层,这让嵌入方保持它们原有的语义。 ## 配置 @@ -35,13 +41,17 @@ OPENAI_API_KEY: sk-… 任何字符串值都能往返,包括多行值,因此不会再有条目因为缺少可用引号样式而不可写。空的存储值等于不存在(seam 规则)——这也正是文档中的空字符串被直接拒绝的原因:`unset` 删除键,而不是把它置空。 +## 权限 + +provider 以 `0700` 创建目录,以 `0600` 创建或原子替换文档。它对*读取*同样守住这条界线:在 POSIX 上,只要文档带有任何 group 或 other 权限位,就会在解析其内容之前失败——启动时与每次 reload 都检查——并在错误里给出 `chmod 600` 的修复命令。Windows 没有可检查的 mode,因此在那里跳过该检查而不是伪造它。 + ## 热重载 外部编辑在快照**整体替换**后按变更引用逐个发布 `credentials/updated`——磁盘上删掉的条目绝不在内存滞留。provider 自己的写入按内容识别,只发布属于该次提交的一个事件。运行期文档不可读或无效时保留最后可用快照并告警;文件不存在即空存储;启动时不可读或无效则响亮失败。 ## 安全边界 -文档在 `0700` 目录下以 `0600` 权限存放,这挡得住其他 OS 用户,**挡不住**模型。工具进程(bash、文件系统工具)以同一用户身份运行,而已交付的 `workspace-write` 文件策略限制的是修改而非读取,因此它们读这个文件与读该用户拥有的任何其他文件毫无二致;也没有任何沙箱模式会把它单独挑出来。harness 真正守住的更窄:它绝不把该文档的解析后路径交给模型,也绝不把它载入进程环境——这与用户的普通环境层 `$DSH_HOME/.env` 不同(见 [app-boot 的个人配置](../../ui/app-boot/README.md#personal-config))——因此要拿到这个值,需要刻意去读一条并未交给 agent 的路径。 +文档在 `0700` 目录下以 `0600` 权限存放,这挡得住其他 OS 用户,**挡不住**模型。工具进程(bash、文件系统工具)以同一用户身份运行,而已交付的 `workspace-write` 文件策略限制的是修改而非读取,因此它们读这个文件与读该用户拥有的任何其他文件毫无二致;也没有任何沙箱模式会把它单独挑出来。harness 真正守住的更窄:它绝不把该文档的解析后路径交给模型,也绝不把它载入进程环境——这与用户的普通环境层 `$DSH_HOME/.env` 不同(见 [app-boot 的 Harness home](../../ui/app-boot/README.md#the-harness-home))——因此要拿到这个值,需要刻意去读一条并未交给 agent 的路径。 这是审慎,不是边界。必须让提供方密钥远离自身 agent 的部署无法靠文件权限做到;OS 钥匙串 provider——一个模型的进程根本读不到的存储——才是延后的答案,它应当作为平级包与本 provider 并列。 @@ -55,9 +65,7 @@ OPENAI_API_KEY: sk-… ## Known Limitations and Deferred Work -- **多行条目拒绝 `set`/`unset`**——行编辑器不改写会被它破坏的条目;`describe` 把它们报为 `writable: false`,编辑必须直接落到文件上。 - **同一引用的并发写入是后写胜出**——写锁加读-改-写让并发写入者不会丢掉彼此的条目,但两个写入者编辑同一个引用时仍以较后的写入为准;没有修订检查。 - **同 UID 进程可以读取该文档**——见[安全边界](#security-boundary):文件效果沙箱模式不会拒绝读取,OS 钥匙串 provider 仍是延后项。 -- **无法表示的值响亮失败**——控制字符,或同时混用两种引号又含反斜杠的值,无法在 dotenv 行格式中往返。 -- **环境变化不可见**——每次解析实时读取 `process.env`,但那里的变化不可能发出事件。 +- **环境变化不可见**:快照在启动时冻结,因此启动之后 export 的变量既不会进入解析,也不会进入 `describe`;要更换来自环境的凭据需要重启。 - **原子但不保证崩溃持久**——继承自 `dsh-atomic-write`;存储在启动时重新读取。 diff --git a/packages/credentials/credentials-local/src/index.ts b/packages/credentials/credentials-local/src/index.ts index 1f0f550c05..a5024353c8 100644 --- a/packages/credentials/credentials-local/src/index.ts +++ b/packages/credentials/credentials-local/src/index.ts @@ -40,7 +40,7 @@ import z from 'schemastery' import { watch as chokidarWatch } from 'chokidar' import { mkdir, readFile, stat } from 'node:fs/promises' import { dirname, join, resolve } from 'node:path' -import { Document, parseDocument } from 'yaml' +import { Document, parseDocument, type YAMLError } from 'yaml' import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' import { resolveDshHome } from '@deepseek-ai/dsh-paths' import { environmentOf } from '@deepseek-ai/dsh-environment' @@ -128,10 +128,10 @@ function isENOENT(error: unknown): boolean { * @param error - the parser's error. * @returns the error code with its line and column. */ -function describeYamlError(error: { code?: string; linePos?: [{ line: number; col: number }, ...unknown[]] }): string { +function describeYamlError(error: YAMLError): string { const at = error.linePos?.[0] const where = at === undefined ? '' : ` at line ${String(at.line)}, column ${String(at.col)}` - return `${error.code ?? 'YAML_ERROR'}${where}` + return `${error.code}${where}` } /** diff --git a/packages/credentials/credentials/src/index.ts b/packages/credentials/credentials/src/index.ts index b640b42881..c6470c1628 100644 --- a/packages/credentials/credentials/src/index.ts +++ b/packages/credentials/credentials/src/index.ts @@ -32,7 +32,7 @@ export function credentialRef(value: string): CredentialRef { export interface ResolvedCredential { /** The non-empty secret value. */ value: string - /** Provider-defined source layer id (the local provider uses `env` and `file`). */ + /** Provider-defined source layer id (the local provider uses `env`, `file`, `project-env`, and `user-env`). */ source: string } diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index 45d9cee054..d456e9282e 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: 020aa65073495526be3f32912b7cd06667c52a2e -README.zh.md: 4c655e90ba00340c056f6ac16159621f7a8c1ddb +README.md: b8619268fc264439184ad51d208996ebb3c64e66 +README.zh.md: 650185083e5b36bc8508bc3847f87bdf5e1c5678 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 020aa65073..b8619268fc 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -15,7 +15,6 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire name: '@deepseek-ai/dsh-llm-deepseek' config: apiKeyEnv: DEEPSEEK_API_KEY # default; resolved per request via ctx.credentials, then the environment - # apiKey: … # literal escape hatch; prefer the reference so no secret enters this file baseURL: https://api.deepseek.com # optional; $DEEPSEEK_BASE_URL then the public API when omitted thinking: enabled # optional; provider default is enabled reasoningEffort: high # optional; off | high | max — omitted ⇒ high @@ -53,7 +52,7 @@ The same exact-model result exposes ordered `off`, `high`, and `max` efforts und Connection facts are not frozen at load. `resolveAdapterOptions` is the one explicit resolve step from raw config to validated facts, and the adapter re-reads them through a thunk **once per operation**: base URL, catalog, request defaults, and idle budget all take effect on the next request, while an in-flight stream keeps the facts it started with. Two optional seams feed that thunk: - **`ctx.settings`** — the plugin registers the `llm-deepseek` namespace with this same `Config` schema and its `cordis.yml` entry as the composition `base`, so a `llm-deepseek:` section in the user settings document overrides any field without a restart. Without a mounted settings service the entry config alone drives the adapter, unchanged. A live settings snapshot that passes the schema but fails a beyond-schema bound (a duplicate catalog id, a broken thinking/effort pair) keeps the last good facts and logs the failure; the entry config itself still fails plugin load. -- **`ctx.credentials`** — the API key resolves per stream call, from the *same* resolved snapshot that supplies the endpoint: a 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. +- **`ctx.credentials`** — the API key resolves per stream call, from the *same* resolved snapshot that supplies the endpoint. Configuration carries only `apiKeyEnv`, never a literal key: the reference resolves through the credential seam, and without a mounted seam through the trusted environment layers. Because credential facts travel with the connection facts, a settings snapshot the resolver rejects contributes neither its endpoint nor its key: the whole previous generation keeps serving. A request with no key anywhere fails with `MISSING_CREDENTIAL` naming every configuration entry point, while the route stays registered and the catalog stays browsable — first-run onboarding is "browse models, store the key, prompt again", with no restart between. The one registration-captured fact is the retry policy: when its resolved value changes, the plugin re-registers the route in place (same adapter instance, one synchronous section), so `ctx.llm.providerRetryPolicy('deepseek-official')` always reports the current policy. @@ -112,7 +111,6 @@ Loop-retained response blocks append to the next request and preserve its earlie ## Known Limitations and Deferred Work - **A settings `models` list replaces the composition list wholesale** — settings-layer merging is per-field, and arrays are one field; per-entry catalog merging would need a keyed shape. -- **`Config.apiKey` is redacted on the wire but still a stored literal** — `describe({ redactSecrets: true })` strips it and reports the slot, so a configuration UI never receives the value; the key is nonetheless stored in the settings document rather than the credential store, so prefer `apiKeyEnv`. - **`tool_choice` is not mapped** — not part of the core vocabulary (MVP cut, shared with the pi-ai twin). - **Requests use raw `fetch`, not `@cordisjs/plugin-http`** — no shared proxy/interception configuration; adoption is deferred until a second adapter wants it (`TODO(http)`). - **Serialization flattens user and tool-result content to text blocks** — plugin-added block types are skipped, and empty tool output crosses the wire as the literal `(no output)`. diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index 4c655e90ba..650185083e 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -15,7 +15,6 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: name: '@deepseek-ai/dsh-llm-deepseek' config: apiKeyEnv: DEEPSEEK_API_KEY # default; resolved per request via ctx.credentials, then the environment - # apiKey: … # literal escape hatch; prefer the reference so no secret enters this file baseURL: https://api.deepseek.com # optional; $DEEPSEEK_BASE_URL then the public API when omitted thinking: enabled # optional; provider default is enabled reasoningEffort: high # optional; off | high | max — omitted ⇒ high @@ -53,7 +52,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: 连接事实不在加载时冻结。`resolveAdapterOptions` 是从原始配置到已校验事实的唯一显式 resolve 步骤,适配器经由一个 thunk **每操作重读一次**:base URL、catalog、请求默认值与 idle 预算都在下一次请求生效,进行中的流则保持其起始事实。两个可选 seam 供给该 thunk: - **`ctx.settings`**——插件用同一份 `Config` schema 注册 `llm-deepseek` namespace,并以其 `cordis.yml` 条目为组合 `base`,因此用户设置文档中的 `llm-deepseek:` 分节可以免重启覆盖任何字段。未挂载 settings 服务时,仅由 entry 配置驱动适配器,行为不变。存活 settings 快照若通过 schema 却违反 schema 之外的约束(重复的 catalog id、无法成立的 thinking/推理强度组合),则保留最后可用事实并记录失败;entry 配置本身仍会使插件加载失败。 -- **`ctx.credentials`**——API 密钥按每次 stream 调用解析,取自与端点*同一*份解析后的快照:非空的字面 `apiKey` 优先,其次经凭据 seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`),最后——仅在未挂载 seam 时——读取原始环境变量。由于凭据事实与连接事实同行,被 resolver 拒绝的 settings 快照既不贡献自己的端点,也不贡献自己的密钥:整个先前世代继续服务。任何地方都没有密钥的请求以 `MISSING_CREDENTIAL` 失败,并点名每个配置入口,同时路由保持注册、catalog 保持可浏览——首次运行的上手流程就是「浏览模型、存入密钥、再次发起提示」,中间无需任何重启。 +- **`ctx.credentials`**——API 密钥按每次 stream 调用解析,取自与端点*同一*份解析后的快照。配置只携带 `apiKeyEnv`,从不携带字面密钥:该引用经凭据 seam 解析,未挂载 seam 时则经受信环境层解析。由于凭据事实与连接事实同行,被 resolver 拒绝的 settings 快照既不贡献自己的端点,也不贡献自己的密钥:整个先前世代继续服务。任何地方都没有密钥的请求以 `MISSING_CREDENTIAL` 失败,并点名每个配置入口,同时路由保持注册、catalog 保持可浏览——首次运行的上手流程就是「浏览模型、存入密钥、再次发起提示」,中间无需任何重启。 唯一在注册期捕获的事实是重试策略:其解析值变化时,插件原地重新注册该路由(同一适配器实例、一个同步区段),因此 `ctx.llm.providerRetryPolicy('deepseek-official')` 始终报告当前策略。 @@ -77,7 +76,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: ## 测试 -单元套件使用本地 `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 文档中的请求。 +单元套件使用本地 `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 文档中的请求。 ## 模型体验 @@ -112,7 +111,6 @@ loop 保留的响应块会追加到下一个请求,并保留其较早可复用 ## 已知限制与暂缓事项 - **settings 的 `models` 列表会整体替换组合列表**:settings 层按字段合并,而数组是单个字段;按条目合并 catalog 需要带键的形状。 -- **`Config.apiKey` 在协议上已脱敏,但仍是一个已存的字面值**:`describe({ redactSecrets: true })` 会把它剥离并报告该槽位,配置 UI 因此永远收不到该值;但这个密钥仍存放在 settings 文档而非凭据存储中,所以请优先使用 `apiKeyEnv`。 - **未映射 `tool_choice`**:它不属于核心词汇(MVP 取舍,与 pi-ai twin 共享)。 - **请求使用原始 `fetch`,而非 `@cordisjs/plugin-http`**:没有共享 proxy/拦截配置;采用暂缓到第二个适配器需要该功能时(`TODO(http)`)。 - **序列化会将 user 与工具结果内容展平为文本块**:会跳过插件添加的块类型,空工具输出会以字面 `(no output)` 通过协议发送。 diff --git a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts index 153281afe3..f1127dbf57 100644 --- a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts @@ -109,7 +109,7 @@ describe('request-level dynamic configuration', () => { 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' }) + const { ctx } = await boot(dir, { baseURL: 'http://127.0.0.1:1' }) await expect(ctx.llm.listModels('deepseek-official')).resolves.toHaveLength(2) await ctx.settings.update(NS, { models: [{ id: 'settings-model', name: 'From Settings' }] }) @@ -120,7 +120,7 @@ describe('request-level dynamic configuration', () => { it('re-registers the route in place when the captured retry policy changes, without an empty-registry window', async () => { const dir = await home() - const { ctx } = await boot(dir, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' }) + const { ctx } = await boot(dir, { baseURL: 'http://127.0.0.1:1' }) // Observing the topology event, not just the end state: disposing and // re-registering also lands on the right final registry, but publishes an @@ -145,7 +145,7 @@ describe('request-level dynamic configuration', () => { 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' }) + const { ctx } = await boot(dir, { 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. diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index c635b1f13e..1e546b6e3a 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -20,8 +20,6 @@ export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000 /** Configuration for one pi-ai provider route; the `providers` dict key IS the route. */ export interface PiAiProviderProfile { - /** Literal provider credential; prefer {@link apiKeyEnv}. With both absent pi-ai uses its provider-native ambient discovery. */ - 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. */ @@ -76,7 +74,6 @@ const thinkingBudgets = z.object({ }) const profile = z.object({ - apiKey: z.string().role('secret'), apiKeyEnv: z.string().role('credential-ref'), baseURL: z.string(), headers: z.dict(z.string()), @@ -126,9 +123,6 @@ export function resolveProfiles( } 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 "${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 "${provider}" has an empty baseURL`) } diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index c138b8f5fc..d5664b7cb2 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -89,7 +89,6 @@ export function apply(ctx: Context, config: Config): void { 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: diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index a0826b3571..daf9c517a4 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, 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' import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' @@ -15,22 +15,32 @@ afterEach(async () => { }) async function harness(baseURL: string, overrides: Record = {}): Promise { + vi.stubEnv('PI_TEST_KEY', 'test-key') const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(LlmPiAi, { - providers: { deepseek: { apiKey: 'test-key', baseURL, ...overrides } }, + providers: { deepseek: { apiKeyEnv: 'PI_TEST_KEY', baseURL, ...overrides } }, }) return ctx } -/** Direct adapter over the real profile resolver, with literal-key resolution. */ -function adapterOf(providers: Record): PiAiAdapter { +/** Direct adapter over the real profile resolver, with a fixed key per call. */ +function adapterOf( + providers: Record, + apiKey: string | undefined = 'test-key', +): PiAiAdapter { return new PiAiAdapter({ profiles: () => resolveProfiles(providers), - resolveApiKey: (_provider, profile) => Promise.resolve(profile.apiKey), + resolveApiKey: () => Promise.resolve(apiKey), }) } +beforeEach(() => { + // Configuration carries only the reference; these mounts resolve it from + // the environment, which is the whole credential plane without a seam. + vi.stubEnv('PI_TEST_KEY', 'test-key') +}) + describe('PiAiAdapter provider routing', () => { it('resolves a catalog model dynamically and uses a private endpoint', async () => { const server = await mockServer([{ events: textEvents }]) @@ -117,7 +127,7 @@ describe('PiAiAdapter provider routing', () => { const ctx = new Context() await ctx.plugin(LlmService) ctx.llm.registerAdapter(['deepseek'], adapterOf({ - deepseek: { apiKey: 'test-key', baseURL: server.url }, + deepseek: { apiKeyEnv: 'PI_TEST_KEY', baseURL: server.url }, })) const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) @@ -146,7 +156,7 @@ describe('PiAiAdapter provider routing', () => { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(LlmPiAi, { - providers: { openai: { apiKey: 'test-key', baseURL: `${server.url}/v1` } }, + providers: { openai: { apiKeyEnv: 'PI_TEST_KEY', baseURL: `${server.url}/v1` } }, }) const result = await assemble(ctx, { provider: 'openai', model: 'gpt-4.1', messages: [] }) expect(result.finish.kind).toBe('error') @@ -166,7 +176,7 @@ describe('PiAiAdapter provider routing', () => { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(LlmPiAi, { - providers: { openai: { apiKey: 'test-key', baseURL: `${server.url}/v1` } }, + providers: { openai: { apiKeyEnv: 'PI_TEST_KEY', baseURL: `${server.url}/v1` } }, }) const result = await assemble(ctx, { provider: 'openai', model: 'gpt-4.1', messages: [] }) @@ -182,7 +192,7 @@ describe('PiAiAdapter provider routing', () => { await ctx.plugin(LlmPiAi, { providers: { openai: { - apiKey: 'test-key', + apiKeyEnv: 'PI_TEST_KEY', baseURL: `${server.url}/api/projects/openai/openai/v1`, headers: { 'api-key': 'test-key', Authorization: '' }, }, @@ -372,7 +382,9 @@ describe('provider profile lifecycle', () => { it('accepts absent credentials for pi-ai ambient authentication', async () => { vi.stubEnv('DEEPSEEK_API_KEY', 'ambient-key') const server = await mockServer([{ events: textEvents }]) - const ctx = await harness(server.url, { apiKey: undefined }) + // A profile that names no reference at all is the one case that defers to + // pi-ai's own provider-native discovery. + const ctx = await harness(server.url, { apiKeyEnv: undefined }) await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) expect(server.headers[0]?.authorization).toBe('Bearer ambient-key') }) @@ -410,8 +422,6 @@ describe('provider profile lifecycle', () => { // 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/) }) @@ -486,7 +496,7 @@ describe('abort wiring', () => { const message = Object.defineProperty({}, 'role', { get() { throw original }, }) - const adapter = adapterOf({ deepseek: { apiKey: 'test-key' } }) + const adapter = adapterOf({ deepseek: {} }) const drain = async (): Promise => { for await (const _chunk of adapter.stream({ provider: 'deepseek', @@ -507,7 +517,7 @@ describe('abort wiring', () => { throw original }, }) - const adapter = adapterOf({ deepseek: { apiKey: 'test-key' } }) + const adapter = adapterOf({ deepseek: {} }) const drain = async (): Promise => { for await (const _chunk of adapter.stream({ provider: 'deepseek', @@ -521,7 +531,7 @@ describe('abort wiring', () => { }) it('resolves catalog endpoints without an override before honoring pre-abort', async () => { - const adapter = adapterOf({ deepseek: { apiKey: 'test-key' } }) + const adapter = adapterOf({ deepseek: {} }) 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 index cc5cd17e55..2c8b07a2aa 100644 --- a/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts @@ -53,7 +53,11 @@ 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, '.credentials.yaml'), 'PI_DYNAMIC_KEY: pk-from-settings\n', { mode: 0o600 }) + await writeFile( + join(dir, '.credentials.yaml'), + 'PI_DYNAMIC_KEY: pk-from-settings\nPI_LIVE_KEY: live-key\nPI_OTHER_KEY: other\n', + { mode: 0o600 }, + ) const server = await mockServer([{ events: textEvents }]) // The exact product posture: `- id: llm-pi-ai` with no config at all. const ctx = await boot(dir, {}) @@ -86,14 +90,19 @@ describe('request-level dynamic profiles', () => { it('adds a provider route from settings and drops it when the user layer resets', async () => { const dir = await home() + await writeFile( + join(dir, '.credentials.yaml'), + 'PI_LIVE_KEY: live-key\nPI_OTHER_KEY: other\n', + { mode: 0o600 }, + ) const server = await mockServer([{ events: textEvents }]) const ctx = await boot(dir, { - providers: { openai: { apiKey: 'k', baseURL: 'http://127.0.0.1:1/v1' } }, + providers: { openai: { apiKeyEnv: 'PI_DYNAMIC_KEY', 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 } }, + providers: { deepseek: { apiKeyEnv: 'PI_LIVE_KEY', baseURL: server.url } }, }) expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai', 'deepseek']) @@ -158,15 +167,20 @@ describe('request-level dynamic profiles', () => { it('keeps serving its routes when a settings-born route collides with another adapter', async () => { const dir = await home() + await writeFile( + join(dir, '.credentials.yaml'), + 'PI_LIVE_KEY: live-key\nPI_OTHER_KEY: other\n', + { mode: 0o600 }, + ) const server = await mockServer([{ events: textEvents }, { events: textEvents }]) - const ctx = await boot(dir, { providers: { openai: { apiKey: 'pk', baseURL: `${server.url}/v1` } } }) + const ctx = await boot(dir, { providers: { openai: { apiKeyEnv: 'PI_LIVE_KEY', 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' }, + openai: { apiKeyEnv: 'PI_LIVE_KEY', baseURL: `${server.url}/v1` }, + anthropic: { apiKeyEnv: 'PI_OTHER_KEY' }, }, }) 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 3f12ef4460..a2727de75f 100644 --- a/packages/llm/llm-pi-ai/tests/sdk-options.spec.ts +++ b/packages/llm/llm-pi-ai/tests/sdk-options.spec.ts @@ -23,7 +23,7 @@ describe('pi-ai SDK retry boundary', () => { }, }) const adapter = new PiAiAdapter({ - profiles: () => resolveProfiles({ openai: { apiKey: 'test-key' } }), + profiles: () => resolveProfiles({ openai: {} }), resolveApiKey: () => Promise.resolve('test-key'), }) const drain = async (): Promise => { diff --git a/packages/ui/app-boot/package.json b/packages/ui/app-boot/package.json index fc4f173263..b978bd33bb 100644 --- a/packages/ui/app-boot/package.json +++ b/packages/ui/app-boot/package.json @@ -27,7 +27,6 @@ ], "license": "BSD-3-Clause", "dependencies": { - "dotenv": "^17.2.0", "js-yaml": "^4.2.0" }, "peerDependencies": { diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index 0f3cbd6687..99220e4269 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -6,10 +6,10 @@ * @module @deepseek-ai/dsh-app-boot */ +import { parseEnv } from 'node:util' import { pathToFileURL } from 'node:url' import { readFileSync } from 'node:fs' import { basename, dirname, resolve } from 'node:path' -import { parse as parseDotenv } from 'dotenv' import * as yaml from 'js-yaml' import { Context, type FiberState } from 'cordis' import Loader, { type Entry, type EntryOptions } from '@cordisjs/plugin-loader' @@ -94,7 +94,13 @@ function readEnvLayer( // ENOENT (no .env) is fine — rely on the ambient environment. return undefined } - const values = parseDotenv(content) + // `node:util`'s parseEnv is the same parser `--env-file` and + // `process.loadEnvFile` use. Checking with a second dialect (npm dotenv) + // would leave the rejection rule and the thing it guards on independently + // maintained parsers: a name Node accepts but the checker does not would + // reach `process.env` unchecked, and `BASH_ENV` there runs a file of the + // project's choosing on every `bash -c` the bash tool issues. + const values = parseEnv(content) as Record for (const name of Object.keys(values)) { if (!isBootstrapOnly(name)) continue throw new Error( @@ -112,9 +118,11 @@ function readEnvLayer( * over the Harness home's `.env`, both under the inherited process * environment. * - * Each layer is parsed and checked before anything is applied, then applied in - * the order that makes the layering `user < project < inherited` — - * `process.loadEnvFile` never replaces a name already set. Values do reach + * Each layer is parsed once, checked, and only then applied — never replacing + * a name already set, which is what makes the layering `user < project < + * inherited`. The single parse is deliberate: the rejection rule and the + * values that reach `process.env` must come from the same parser, or a name + * one dialect accepts and the other misses would slip past the check. Values do reach * `process.env`, because a user's own `--config` tree and third-party * libraries read it; the returned snapshot is the authority for everything the * harness itself resolves, since `process.env` alone cannot say whether a @@ -144,8 +152,18 @@ export function loadLayeredEnv( // Parse both layers first: a rejection must not leave one file applied. const project = readEnvLayer(binName, cwd, warn) const user = home === resolve(cwd) ? undefined : readEnvLayer(binName, home, warn) - if (project !== undefined) process.loadEnvFile(project.path) - if (user !== undefined) process.loadEnvFile(user.path) + // Assign the entries this function already parsed and checked, rather than + // re-reading each file through `process.loadEnvFile`. One parse means the + // snapshot, the rejection rule, and `process.env` can never disagree about + // what a file contains. Skipping names already set reproduces the + // never-replace behavior that makes the layering `user < project < + // inherited`. + for (const layer of [project, user]) { + if (layer === undefined) continue + for (const [name, value] of Object.entries(layer.values)) { + if (process.env[name] === undefined) process.env[name] = value + } + } return createEnvironmentSnapshot([ { source: 'process', values: inherited }, ...project === undefined ? [] : [{ source: 'project-env' as const, path: project.path, values: project.values }], diff --git a/packages/util/environment/src/index.ts b/packages/util/environment/src/index.ts index 6e27656805..11014f64b5 100644 --- a/packages/util/environment/src/index.ts +++ b/packages/util/environment/src/index.ts @@ -69,6 +69,16 @@ export interface EnvironmentSnapshot { readonly layers: readonly EnvironmentLayer[] } +/** + * The map key one variable name resolves under. Windows treats environment + * names case-insensitively; every other platform does not. + * @param name - the variable name as written. + * @returns the key to store and look up by. + */ +function lookupKey(name: string): string { + return process.platform === 'win32' ? name.toUpperCase() : name +} + /** One layer's raw contents, as {@link createEnvironmentSnapshot} receives them. */ export interface EnvironmentLayerInput { source: EnvironmentSource @@ -84,18 +94,24 @@ export interface EnvironmentLayerInput { */ export function createEnvironmentSnapshot(layers: readonly EnvironmentLayerInput[]): EnvironmentSnapshot { // Copied per layer so a later mutation of `process.env` — or of a caller's - // own object — cannot change what this snapshot reports. + // own object — cannot change what this snapshot reports. Windows environment + // names are case-insensitive, so lookups there fold case: otherwise a shell + // that set `deepseek_api_key` would be invisible to a consumer asking for + // `DEEPSEEK_API_KEY`, and a lower-ranked layer spelling it in caps would win + // a decision the launch had already made. POSIX names are case-sensitive and + // must stay exact. const bySource = new Map }>() for (const layer of layers) { bySource.set(layer.source, { ...layer.path === undefined ? {} : { path: layer.path }, - values: new Map(Object.entries(layer.values)), + values: new Map(Object.entries(layer.values).map(([name, value]) => [lookupKey(name), value])), }) } const getFrom = (name: string, sources: readonly EnvironmentSource[]): EnvironmentEntry | undefined => { + const key = lookupKey(name) for (const source of sources) { const layer = bySource.get(source) - const value = layer?.values.get(name) + const value = layer?.values.get(key) if (value === undefined) continue return { value, source, ...layer?.path === undefined ? {} : { path: layer.path } } } @@ -154,13 +170,21 @@ const BOOTSTRAP_NAMES = new Set([ 'BASH_ENV', 'ENV', 'SHELLOPTS', 'BASHOPTS', 'PERL5OPT', 'PERL5LIB', 'PYTHONSTARTUP', 'PYTHONPATH', 'RUBYOPT', 'RUBYLIB', 'JAVA_TOOL_OPTIONS', '_JAVA_OPTIONS', 'JDK_JAVA_OPTIONS', - // Version-control hooks that run a command on the setter's behalf. + 'PYTHONHOME', + // Version-control hooks that run a command on the setter's behalf, and the + // config redirections that can define such a hook indirectly (a substituted + // git config file can set core.pager or a credential helper). 'GIT_SSH', 'GIT_SSH_COMMAND', 'GIT_EXTERNAL_DIFF', 'GIT_PAGER', 'GIT_EDITOR', + 'GIT_ASKPASS', 'SSH_ASKPASS', + 'GIT_CONFIG_GLOBAL', 'GIT_CONFIG_SYSTEM', 'GIT_CONFIG_COUNT', 'EDITOR', 'VISUAL', 'PAGER', // Network reach and trust. 'SSL_CERT_FILE', 'SSL_CERT_DIR', 'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY', 'REQUESTS_CA_BUNDLE', 'CURL_CA_BUNDLE', + // Turns off TLS verification outright, which is the sharpest form of + // "how the network is trusted". + 'NODE_TLS_REJECT_UNAUTHORIZED', ]) /** Name prefixes no discovered file may set. */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 18b62cae80..adeea9a056 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5657,9 +5657,6 @@ importers: packages/ui/app-boot: dependencies: - dotenv: - specifier: ^17.2.0 - version: 17.4.2 js-yaml: specifier: ^4.2.0 version: 4.2.0 @@ -9752,10 +9749,6 @@ 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} @@ -14843,8 +14836,6 @@ 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/src/deepseek_harness_runtime/runtime/cordis.yml b/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml index 2f35e58d43..318bda59b0 100644 --- a/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml +++ b/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml @@ -13,13 +13,12 @@ workspaceContext: maxBytes: 65536 -# Stock DeepSeek adapters. Loading requires an API key; initialize and shutdown -# may use a dummy key because they do not call the model. +# Stock DeepSeek adapters. The adapter resolves DEEPSEEK_API_KEY through the +# credential seam and, with no provider mounted here, from the launching +# environment; DEEPSEEK_BASE_URL follows the same environment ladder. Neither +# is inlined, so this file names no secret and no route. - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' - config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_BASE_URL # JSONL persistence; $DSH_SESSION_ROOT wins over ./.sessions in the process cwd. - id: sessions diff --git a/scripts/verify-config-source-ownership.ts b/scripts/verify-config-source-ownership.ts index d346b19233..8b59957c4b 100644 --- a/scripts/verify-config-source-ownership.ts +++ b/scripts/verify-config-source-ownership.ts @@ -72,9 +72,21 @@ const ENV_READ_ALLOWLIST: Readonly> = { } /** Shipped Cordis configuration these rules apply to. */ -const SHIPPED_CONFIG_GLOBS = ['apps/*/config/*.yml', 'examples/*/*.cordis.yml', 'examples/*/cordis.yml'] +const SHIPPED_CONFIG_GLOBS = [ + 'apps/*/config/*.yml', + 'examples/*/*.cordis.yml', + 'examples/*/cordis.yml', + // The Python runtime ships its own default composition inside the wheel. + 'python/*/src/**/cordis.yml', +] -/** Config keys that must never be inlined from the environment. */ +/** + * Config keys that must never be inlined from the environment. Line-anchored + * on purpose: this is a tripwire for the shape people actually write, not a + * YAML analysis. A folded scalar or a block-literal spelling would slip past + * it, which is acceptable because the rule it guards is also stated in the + * owning Agent Note and enforced by the adapters' own resolution. + */ const INLINE_DENY = /^\s*(apiKey|baseURL|apiKeyEnv|authToken|headers)\s*:\s*!!js\b/ const failures: string[] = [] From 286f356942207ac60b6a898188d8d90f1316814b Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 5 Aug 2026 11:29:36 +0800 Subject: [PATCH 07/27] docs: narrow the composition claims to what survived the TUI removal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Master's #1369 deleted the TUI, the meta and upgrade subcommands, and the whole-tree --config-replace path. These notes were written before that landed and still promised a flag the CLI no longer registers, and named it as the lever a deployment uses to pin a field against a user's stored settings — which now has no CLI equivalent at all. State what shipped: every booting surface takes --config, dsh -p is the surface this change actually gave it to, and a deployment that must win against stored settings ships its own bin or loader tree. Each note cross-links #1369's own note rather than restating the removal, and the shared-base note moves its --config-replace sentences to past tense. --- .../2026-08-04-configuration-source-ownership.i18n.yaml | 4 ++-- .../2026-08-04-configuration-source-ownership.md | 6 +++--- .../2026-08-04-configuration-source-ownership.zh.md | 6 +++--- .../2026-07-29-shared-base-config-overlays.i18n.yaml | 4 ++-- .../2026-07-29-shared-base-config-overlays.md | 2 +- .../2026-07-29-shared-base-config-overlays.zh.md | 2 +- ...2026-08-04-remove-personal-composition-layer.i18n.yaml | 4 ++-- .../2026-08-04-remove-personal-composition-layer.md | 8 ++++---- .../2026-08-04-remove-personal-composition-layer.zh.md | 8 ++++---- .../2026-08-04-remove-profile-json-entry.i18n.yaml | 4 ++-- .../2026-08-04-remove-profile-json-entry.md | 2 +- .../2026-08-04-remove-profile-json-entry.zh.md | 2 +- 12 files changed, 26 insertions(+), 26 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml index cbce8a65e8..51d58cd442 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.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 .agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md -2026-08-04-configuration-source-ownership.md: 97daf3c430ba09c000eab947e159030568a7f89d -2026-08-04-configuration-source-ownership.zh.md: 424c47d36f47136669f4e02f980e63cabd203f9c +2026-08-04-configuration-source-ownership.md: 7f8dba2e4879fee34c4526bd73436b4c8ddd13aa +2026-08-04-configuration-source-ownership.zh.md: 26fdad39887c07fe420e1c37d49b252eeeb2e3ae diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md index 97daf3c430..7f8dba2e48 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md @@ -21,13 +21,13 @@ And `!!js process.env.X` in the shipped composition made the same value reachabl ```text explicit for this run per-operation override, CLI argument > user settings settings.yaml -> composition --config / --config-replace, shipped base +> composition --config overlay, shipped base > this launch's shell inherited process environment > discovered file $DSH_HOME/.env > defaults schema default, provider public default ``` -Settings sit above composition because that is what the [settings seam](2026-07-28-user-settings-seam.md) does: a plugin registers its cordis entry config as the `base` layer and the user's section layers over it, and the seam cannot tell a value the shipped base set from one a `--config` overlay set — both arrive as entry config. A deployment that must pin a field against a user's stored settings therefore uses `--config-replace`, which bypasses the tree the settings base is derived from. Composition still outranks the environment, so a stale `DEEPSEEK_BASE_URL` in a shell cannot rewrite a configured endpoint. +Settings sit above composition because that is what the [settings seam](2026-07-28-user-settings-seam.md) does: a plugin registers its cordis entry config as the `base` layer and the user's section layers over it, and the seam cannot tell a value the shipped base set from one a `--config` overlay set — both arrive as entry config. The product CLI has no lever above stored settings: `--config-replace` was removed with the TUI ([explicit-config entrypoint](../simplification/2026-08-03-explicit-config-dsh-entrypoint.md)), so a deployment that must pin a field against a user's settings ships its own bin or loader tree, or mounts no settings provider at all. Composition still outranks the environment, so a stale `DEEPSEEK_BASE_URL` in a shell cannot rewrite a configured endpoint. **Credentials keep a narrower, separate ordering**, and this note does not unify them: @@ -54,7 +54,7 @@ The line is that these take effect with no user action, before any turn, outside - The web credential form now takes effect against an older key in the user's `.env`; only a key exported in the launching shell still makes it read-only, and the diagnostic says so. - A `.env` holding `DSH_*`, `PATH`, or a proxy variable fails the launch instead of being applied. Developers keeping switches in a repository `.env` move them to their shell — a deliberate, loud break. -- `--config` is no longer overridable by a stale shell endpoint. It is still overridable by a user's stored `settings.yaml`, which is the settings seam's layering and not something this note changes; a deployment that must win against stored settings uses `--config-replace`. +- `--config` is no longer overridable by a stale shell endpoint. It is still overridable by a user's stored `settings.yaml`, which is the settings seam's layering and not something this note changes; the product CLI offers no flag above it, so a deployment that must win against stored settings owns its own bin or loader tree. - Not solved: the layers are still materialized into `process.env`, so ordinary project variables continue to reach child processes under the subprocess scrub. Bootstrap variables cannot come from a file at all, which closes the escalation path; a project `.env` setting something like `GIT_SSH_COMMAND` for the tools an agent runs remains possible and is recorded as a limitation on the package. - The LLM adapters no longer accept a literal `apiKey`: configuration carries the reference and nothing else, so a settings document cannot become a second credential store. No adapter namespace is strict, so writing one is dropped rather than rejected. The web-search providers still declare a `role('secret')` literal key; they register no settings namespace, so nothing can shadow a stored credential through them, but the claim is about the adapters rather than the repository as a whole. - Exa and Perplexity still capture their key at load time rather than through the credential seam. They no longer read raw `process.env` — they resolve through the trusted layers — but converting them to per-request seam resolution is separate work. diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md index 424c47d36f..26fdad3988 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md @@ -21,7 +21,7 @@ endpoint 可以被项目重定向。调用目录的 `.env` 和其他层一样会 ```text explicit for this run per-operation override, CLI argument > user settings settings.yaml -> composition --config / --config-replace, shipped base +> composition --config overlay, shipped base > this launch's shell inherited process environment > discovered file $DSH_HOME/.env > defaults schema default, provider public default @@ -29,7 +29,7 @@ explicit for this run per-operation override, CLI argument 自上而下依次是:本次运行的显式意图、用户 settings、composition、本次启动的 shell、被发现的文件、默认值。 -settings 在 composition 之上,因为 [settings seam](2026-07-28-user-settings-seam.md) 就是这么做的:插件把自己的 cordis entry config 注册为 `base` 层,用户 section 叠加其上,而 seam 无法区分某个值是交付基座设的还是 `--config` overlay 设的——两者都以 entry config 的形式抵达。因此,需要把某字段钉死、不被用户已存 settings 覆盖的部署方,应使用 `--config-replace`,它绕过了 settings base 所派生的那棵树。composition 仍然高于环境,所以 shell 里陈旧的 `DEEPSEEK_BASE_URL` 无法改写已配置的 endpoint。 +settings 在 composition 之上,因为 [settings seam](2026-07-28-user-settings-seam.md) 就是这么做的:插件把自己的 cordis entry config 注册为 `base` 层,用户 section 叠加其上,而 seam 无法区分某个值是交付基座设的还是 `--config` overlay 设的——两者都以 entry config 的形式抵达。产品 CLI(命令行界面)没有高于已存 settings 的手段:`--config-replace` 已随 TUI 一并移除(见[显式配置入口](../simplification/2026-08-03-explicit-config-dsh-entrypoint.md)),因此需要把某字段钉死、不被用户已存 settings 覆盖的部署方,应自带 bin 或 loader 配置树,或者干脆不挂载 settings provider。composition 仍然高于环境,所以 shell 里陈旧的 `DEEPSEEK_BASE_URL` 无法改写已配置的 endpoint。 **凭据保留一条更窄的独立顺序**,本 Note 不把它并入上表: @@ -56,7 +56,7 @@ inherited process environment (read-only, wins) - Web 凭据表单现在能压过用户 `.env` 里更旧的密钥;只有在启动 shell 里 export 的密钥才会让它变成只读,诊断信息也会这么说。 - 含 `DSH_*`、`PATH` 或 proxy 变量的 `.env` 会导致启动失败而不是被应用。把开关放在仓库 `.env` 里的开发者需要改放到 shell——这是一次刻意且响亮的破坏。 -- `--config` 不再会被陈旧的 shell endpoint 覆盖。但它仍然会被用户已存的 `settings.yaml` 覆盖,这是 settings seam 的分层方式,本 Note 不改变它;需要压过已存 settings 的部署方应使用 `--config-replace`。 +- `--config` 不再会被陈旧的 shell endpoint 覆盖。但它仍然会被用户已存的 `settings.yaml` 覆盖,这是 settings seam 的分层方式,本 Note 不改变它;产品 CLI 没有高于它的标志,因此需要压过已存 settings 的部署方要自带 bin 或 loader 配置树。 - 未解决的:各层仍然会被物化进 `process.env`,因此普通项目变量继续按子进程清洗规则抵达子进程。bootstrap 变量完全不能来自文件,提权路径已封闭;项目 `.env` 为 agent 运行的工具设置诸如 `GIT_SSH_COMMAND` 之类的变量仍然可能,已作为限制记录在该包上。 - LLM 适配器不再接受字面 `apiKey`:配置只携带引用,因此 settings 文档无法成为第二个凭据存储。由于没有任何适配器 namespace 是 strict 的,写入该键会被 schema 丢弃而不是报错。web-search 提供方仍声明 `role('secret')` 的字面密钥字段;它们不注册 settings namespace,因此无法借此遮蔽已存凭据,但这条声明的范围是适配器,而不是整个仓库。 - Exa 与 Perplexity 仍在加载时捕获密钥,而不是经凭据 seam。它们不再读裸 `process.env`——改为经受信层解析——但把它们改造成按请求经 seam 解析是另一件事。 diff --git a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.i18n.yaml index 37df0f897d..0dfa674535 100644 --- a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.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 .agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md -2026-07-29-shared-base-config-overlays.md: 80418447cf45f9f4aa279d1b46b5181d383d0a12 -2026-07-29-shared-base-config-overlays.zh.md: c75dd66c8fb299d4f2a57f7e9ea1acb54a2f8951 +2026-07-29-shared-base-config-overlays.md: 8e83282ed7ea2d3264f38bee8c29f72d0288aed5 +2026-07-29-shared-base-config-overlays.zh.md: bc2be5d74f57df7e15a4c7170a1162398229df5d diff --git a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md index 80418447cf..8e83282ed7 100644 --- a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md +++ b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md @@ -20,7 +20,7 @@ One shared base, one overlay per surface, composed as sibling patch lists. Precedence is list order, last write winning per row: base, then the surface overlay, then a `--config` overlay, then the launcher's own flag patches. The personal `~/.dsh/config.yaml` sat in the `--config` slot until it was [removed with the personal composition layer](../simplification/2026-08-04-remove-personal-composition-layer.md). -`--config ` applies an overlay over the shipped tree (at the time, **instead of** the personal overlay, so a demo or test tree never inherited the user's provider and model). `--config-replace ` boots a file as the entire tree, bypassing base, surface overlay, and personal overlay alike; that is what the old `--config` did, so trees like `examples/web-cordis` moved to the new flag. Both flags survive the `/resume` execve handoff, or resuming would silently change the agent. +`--config ` applies an overlay over the shipped tree (at the time, **instead of** the personal overlay, so a demo or test tree never inherited the user's provider and model). `--config-replace ` booted a file as the entire tree, bypassing base, surface overlay, and personal overlay alike; that is what the old `--config` did, so trees like `examples/web-cordis` moved to the new flag. Both flags survived the `/resume` execve handoff, or resuming would silently have changed the agent. That flag and the resume handoff were later removed with the TUI ([explicit-config entrypoint](2026-08-03-explicit-config-dsh-entrypoint.md)). A patch replaces its target row's whole `config` rather than merging, which shapes the split: a row whose value differs per surface lives in the overlays, never in the base, so no row is patched by three layers at once. Session identity therefore cannot ride a config key at all — it moved to `dsh-agent-loop`'s `CONFIGURED_AGENT_IDENTITIES_KEY`, as the launcher-owned identity record documented. diff --git a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.zh.md b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.zh.md index c75dd66c8f..bc2be5d74f 100644 --- a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.zh.md @@ -20,7 +20,7 @@ Status: implemented 优先级即列表顺序,逐配置项后写者胜:base,然后是 surface overlay,接着是 `--config` overlay,最后是启动器自身的 flag patch。个人 `~/.dsh/config.yaml` 曾占据 `--config` 这一槽位,直到它[已随个人 composition 层一并删除](../simplification/2026-08-04-remove-personal-composition-layer.md)。 -`--config ` 在已交付配置树上应用一个 overlay(当时是**取代**个人 overlay,因此 demo 或测试用的树绝不会继承用户的 provider 与 model)。`--config-replace ` 则把某个文件作为整棵树启动,同时绕过 base、surface overlay 与个人 overlay;这正是旧 `--config` 的行为,所以像 `examples/web-cordis` 这样的树改用了新 flag。两个 flag 都会在 `/resume` 的 execve 交接中保留,否则 resume 会静默更换 agent。 +`--config ` 在已交付配置树上应用一个 overlay(当时是**取代**个人 overlay,因此 demo 或测试用的树绝不会继承用户的 provider 与 model)。`--config-replace ` 当时把某个文件作为整棵树启动,同时绕过 base、surface overlay 与个人 overlay;这正是旧 `--config` 的行为,所以像 `examples/web-cordis` 这样的树改用了新 flag。两个 flag 当时都会在 `/resume` 的 execve 交接中保留,否则 resume 会静默更换 agent。该标志与 resume 交接后来随 TUI 一并移除(见[显式配置入口](2026-08-03-explicit-config-dsh-entrypoint.md))。 patch 会整体替换目标配置项的 `config` 而不合并,这决定了拆分方式:取值因 surface 而异的配置项住在 overlay 中,绝不住在 base 里,从而没有任何配置项会被三层同时 patch。因此会话身份根本不能经由配置键传递——它迁移到了 `dsh-agent-loop` 的 `CONFIGURED_AGENT_IDENTITIES_KEY`,正如启动器持有身份的记录所述。 diff --git a/.agents/notes/implemented/simplification/2026-08-04-remove-personal-composition-layer.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-04-remove-personal-composition-layer.i18n.yaml index 11239d3c23..e000e463b7 100644 --- a/.agents/notes/implemented/simplification/2026-08-04-remove-personal-composition-layer.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-08-04-remove-personal-composition-layer.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 .agents/notes/implemented/simplification/2026-08-04-remove-personal-composition-layer.md -2026-08-04-remove-personal-composition-layer.md: 941e2248e15e235037e6bd48dcb3ba6c80bd83dd -2026-08-04-remove-personal-composition-layer.zh.md: 6c6f3ecd541590368624f4ed4bd409321a2f9772 +2026-08-04-remove-personal-composition-layer.md: e41109d4c141f55e511e102f99e87ef5c696ac47 +2026-08-04-remove-personal-composition-layer.zh.md: b5e47e188db6dfccb55b2800329a2f2e6cd2787f diff --git a/.agents/notes/implemented/simplification/2026-08-04-remove-personal-composition-layer.md b/.agents/notes/implemented/simplification/2026-08-04-remove-personal-composition-layer.md index 941e2248e1..e41109d4c1 100644 --- a/.agents/notes/implemented/simplification/2026-08-04-remove-personal-composition-layer.md +++ b/.agents/notes/implemented/simplification/2026-08-04-remove-personal-composition-layer.md @@ -12,17 +12,17 @@ A patch replaces its target row's whole `config`, so a personal file written mon It also competed with typed settings for the same values. `llm-deepseek` and `llm-pi-ai` register settings namespaces, and the same fields are reachable by patching their rows — so which one wins is a function of layer order, not of what the value means. That is the ownership ambiguity the [user-settings seam](../architecture/2026-07-28-user-settings-seam.md) exists to remove. -Finally the escape hatch it was supposed to be redundant with did not cover every surface: `dsh -p`, `dsh meta`, and `dsh upgrade` all rejected `--config`. For those surfaces the implicit file was not one composition route among two — it was the only one. +Finally the escape hatch it was supposed to be redundant with did not cover every surface: `dsh -p` rejected `--config`, and so did the `meta` and `upgrade` subcommands of the time. For those surfaces the implicit file was not one composition route among two — it was the only one. ## Decision The implicit layer is deleted and the explicit one is completed. -**Every booting surface takes `--config` and `--config-replace`.** `dsh -p`, `dsh meta`, and `dsh upgrade` join the TUI, so naming a tree is available wherever a tree boots. A headless `--config-replace` tree must still mount a webserver row, because that surface reaches its own agent over the same HTTP gateway the browser uses; `AppCLIEntry` now names that contract in the failure instead of reporting a bare missing service. +**Every booting surface takes `--config`.** `dsh -p` joins the surfaces that already had it, so naming an overlay is available wherever a tree boots. The TUI, `meta`, and `upgrade` were removed in parallel by the [explicit-config entrypoint](2026-08-03-explicit-config-dsh-entrypoint.md), which also deleted the whole-tree `--config-replace` path; what remains of this change on that side is headless, which previously rejected the flag and had the implicit file as its only composition route. **`$DSH_HOME/config.yaml` is not read, watched, or dumped.** `PERSONAL_CONFIG_FILENAME`, `loadPersonalPatches`, `watchPersonalPatches`, and the config-only HMR row mounted for it are deleted. A file left at that path is inert. The Harness home keeps `settings.yaml`, `.credentials.yaml`, and `.env`; an overlay may still live there, but as a path to name, not a layer to discover. -`--config` therefore changes meaning slightly: it used to *replace* the personal overlay, and now it simply *is* the user overlay. `--config-replace` is unchanged. +`--config` therefore changes meaning slightly: it used to *replace* the personal overlay, and now it simply *is* the user overlay. Everyday capabilities keep their owners. Model and provider parameters already belong to the adapters' typed settings namespaces. The `repository-plugins` row ships mounted with an empty list, so a repository Plugin list is a `--config` overlay today and a settings namespace when one lands. MCP servers stay a `--config` composition, which is what [the CLI README](../../../../apps/cli/README.md) now documents. @@ -44,4 +44,4 @@ There is no migration and no deprecation diagnostic: the product is unreleased, **Delete it only after the settings-driven repository and MCP managers exist.** Rejected as an unnecessary dependency once `--config` reached every surface: the managers make those two cases *nicer*, but with the flag available everywhere, nothing is lost by removing the implicit layer first. -**Keep it for `dsh -p` alone, where no flag existed.** Rejected: that is the surface with the strongest case for explicitness. A CI or scripted run should name its composition rather than inherit whatever the machine holds. +**Keep it for `dsh -p` alone, where no flag existed.** Rejected: that is the surface with the strongest case for explicitness. A CI or scripted run should name its composition rather than inherit whatever the machine holds — which is why `-p` gained `--config` here instead. diff --git a/.agents/notes/implemented/simplification/2026-08-04-remove-personal-composition-layer.zh.md b/.agents/notes/implemented/simplification/2026-08-04-remove-personal-composition-layer.zh.md index 6c6f3ecd54..b5e47e188d 100644 --- a/.agents/notes/implemented/simplification/2026-08-04-remove-personal-composition-layer.zh.md +++ b/.agents/notes/implemented/simplification/2026-08-04-remove-personal-composition-layer.zh.md @@ -12,17 +12,17 @@ patch 会替换目标行的整个 `config`,因此几个月前写下的个人 它还在同一批值上与类型化 settings 争夺所有权。`llm-deepseek` 与 `llm-pi-ai` 都注册了 settings namespace,而同样的字段也能通过 patch 它们的行抵达——于是谁赢取决于层序,而不取决于这个值的语义。这正是 [user-settings seam](../architecture/2026-07-28-user-settings-seam.md) 要消除的所有权歧义。 -最后,本应与它互为冗余的那条显式通道并未覆盖所有界面:`dsh -p`、`dsh meta` 和 `dsh upgrade` 都拒绝 `--config`。对这些界面来说,隐式文件不是两条 composition 路径之一——它是唯一的一条。 +最后,本应与它互为冗余的那条显式通道并未覆盖所有界面:`dsh -p` 拒绝 `--config`,当时的 `meta` 与 `upgrade` 子命令同样如此。对这些界面来说,隐式文件不是两条 composition 路径之一——它是唯一的一条。 ## Decision 删掉隐式的那一层,并把显式的那一层补完整。 -**每个会启动的界面都接受 `--config` 与 `--config-replace`。** `dsh -p`、`dsh meta` 和 `dsh upgrade` 与 TUI 看齐,因此只要有配置树启动的地方,就能点名一棵树。无头模式下的 `--config-replace` 树仍必须挂载 webserver 行,因为该界面是通过浏览器所用的同一个 HTTP 网关访问自己的 agent 的;`AppCLIEntry` 现在会在失败信息里说明这条契约,而不是只报告某个服务缺失。 +**每个会启动的界面都接受 `--config`。** `dsh -p` 与本来就有该标志的界面看齐,因此只要有配置树启动的地方,就能点名一份 overlay。TUI、`meta` 与 `upgrade` 由[显式配置入口](2026-08-03-explicit-config-dsh-entrypoint.md)并行移除,它同时删除了整棵树的 `--config-replace` 路径;本次变更在这一侧留下的就是 headless——它此前拒绝该标志,隐式文件是它唯一的 composition 路径。 **`$DSH_HOME/config.yaml` 不再被读取、监视或 dump。** `PERSONAL_CONFIG_FILENAME`、`loadPersonalPatches`、`watchPersonalPatches`,以及专为它挂载的那一行 config-only HMR,全部删除。留在该路径上的文件是惰性的。Harness home 仍然保有 `settings.yaml`、`.credentials.yaml` 和 `.env`;overlay 也仍然可以放在那里,但它是一条待点名的路径,而不是一层待发现的配置。 -因此 `--config` 的含义略有变化:它过去是*替代*个人 overlay,现在它本身*就是*用户 overlay。`--config-replace` 保持不变。 +因此 `--config` 的含义略有变化:它过去是*替代*个人 overlay,现在它本身*就是*用户 overlay。 日常能力各自保有归属。模型与 provider 参数已经属于各适配器的类型化 settings namespace。`repository-plugins` 行随交付配置以空列表挂载,因此仓库插件列表今天是一个 `--config` overlay,等 settings namespace 落地后归它。MCP 服务器仍然是 `--config` composition,这也是 [CLI README](../../../../apps/cli/README.md) 现在的写法。 @@ -44,4 +44,4 @@ patch 会替换目标行的整个 `config`,因此几个月前写下的个人 **等 settings 驱动的 repository 与 MCP manager 落地后再删。** 在 `--config` 覆盖所有界面之后,这条依赖已无必要,故否决:那两个 manager 会让这两种场景*更好用*,但只要标志处处可用,先删掉隐式层就不损失任何东西。 -**只为 `dsh -p` 保留它,因为那里原本没有标志。** 否决:那恰恰是最需要显式的界面。CI 或脚本化运行应当点名自己的 composition,而不是继承机器上恰好存在的东西。 +**只为 `dsh -p` 保留它,因为那里原本没有标志。** 否决:那恰恰是最需要显式的界面。CI 或脚本化运行应当点名自己的 composition,而不是继承机器上恰好存在的东西——所以这里改为给 `-p` 补上 `--config`。 diff --git a/.agents/notes/implemented/simplification/2026-08-04-remove-profile-json-entry.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-04-remove-profile-json-entry.i18n.yaml index 60bfb506ae..5059240ce9 100644 --- a/.agents/notes/implemented/simplification/2026-08-04-remove-profile-json-entry.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-08-04-remove-profile-json-entry.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 .agents/notes/implemented/simplification/2026-08-04-remove-profile-json-entry.md -2026-08-04-remove-profile-json-entry.md: 8ca81e2364e095d90c87febfe705ddec14269bf4 -2026-08-04-remove-profile-json-entry.zh.md: bbc3957d11a2051e7c1f9eaaed52d8af38fa1e5b +2026-08-04-remove-profile-json-entry.md: 90d90adc8c4a6828f3ce49253150d09527a8304a +2026-08-04-remove-profile-json-entry.zh.md: 60646a0ffc76ec967fef57f54ff0865b3c842754 diff --git a/.agents/notes/implemented/simplification/2026-08-04-remove-profile-json-entry.md b/.agents/notes/implemented/simplification/2026-08-04-remove-profile-json-entry.md index 8ca81e2364..90d90adc8c 100644 --- a/.agents/notes/implemented/simplification/2026-08-04-remove-profile-json-entry.md +++ b/.agents/notes/implemented/simplification/2026-08-04-remove-profile-json-entry.md @@ -12,7 +12,7 @@ Meanwhile the fields it mapped acquired owners elsewhere. `provider` and `model` ## Decision -`PROFILE_DIR`, `PROFILE_FILE`, `ProfileMapping`, `PROFILE_MAPPINGS`, and `readProfile()` are deleted along with the patch source that consumed them. `AppCLIEntry` composes its patches from CLI flags and the resolved frontend `distIndex` only; the layers around it — shipped base, surface overlay, `--config` or the personal overlay, and `--config-replace` — are unchanged. +`PROFILE_DIR`, `PROFILE_FILE`, `ProfileMapping`, `PROFILE_MAPPINGS`, and `readProfile()` are deleted along with the patch source that consumed them. `AppCLIEntry` composes its patches from CLI flags and the resolved frontend `distIndex` only; the layers around it — shipped base, surface overlay, and the `--config` overlay — are unchanged. A `.dsh-tmp-profile/config.json` on disk is now ignored completely. There is no migration, no replacement format, and no deprecation diagnostic: the file never had a producer, so there is no installed base to carry forward, and the [pre-release stance](../../../../AGENTS.md) rejects compatibility shims. diff --git a/.agents/notes/implemented/simplification/2026-08-04-remove-profile-json-entry.zh.md b/.agents/notes/implemented/simplification/2026-08-04-remove-profile-json-entry.zh.md index bbc3957d11..60646a0ffc 100644 --- a/.agents/notes/implemented/simplification/2026-08-04-remove-profile-json-entry.zh.md +++ b/.agents/notes/implemented/simplification/2026-08-04-remove-profile-json-entry.zh.md @@ -12,7 +12,7 @@ Status: implemented ## Decision -`PROFILE_DIR`、`PROFILE_FILE`、`ProfileMapping`、`PROFILE_MAPPINGS` 和 `readProfile()` 连同消费它们的那个 patch 来源一并删除。`AppCLIEntry` 现在只从 CLI 标志和解析出的前端 `distIndex` 合成 patch;它周围的各层——交付基座、surface overlay、`--config` 或个人 overlay、以及 `--config-replace`——保持不变。 +`PROFILE_DIR`、`PROFILE_FILE`、`ProfileMapping`、`PROFILE_MAPPINGS` 和 `readProfile()` 连同消费它们的那个 patch 来源一并删除。`AppCLIEntry` 现在只从 CLI 标志和解析出的前端 `distIndex` 合成 patch;它周围的各层——交付基座、surface overlay、以及 `--config` overlay——保持不变。 磁盘上的 `.dsh-tmp-profile/config.json` 现在被完全忽略。没有迁移、没有替代格式、也没有弃用诊断:该文件从来没有生产方,因此不存在需要承接的存量,而[未发布阶段的立场](../../../../AGENTS.md)拒绝兼容垫片。 From 84b119619ae3ad5482cd36eb874e728ea1a9b1e3 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 5 Aug 2026 12:42:06 +0800 Subject: [PATCH 08/27] chore(environment): match the tightened published-files constraint Master narrowed `files` to the built entrypoints plus declarations; the new environment package still carried declaration maps and `src`. --- packages/util/environment/package.json | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/util/environment/package.json b/packages/util/environment/package.json index 94a2a76ef6..6029a9f52a 100644 --- a/packages/util/environment/package.json +++ b/packages/util/environment/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { From 2c47636a85b2ac4dc38c399a58b2923456913ce3 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 5 Aug 2026 12:45:18 +0800 Subject: [PATCH 09/27] docs(environment): state the snapshot's name-matching contract The Windows case-folding in the lookup was implemented without a user-facing contract. Name matching follows the platform, and the reason it must is the layer ranking it would otherwise invert. --- packages/util/environment/README.i18n.yaml | 4 ++-- packages/util/environment/README.md | 2 ++ packages/util/environment/README.zh.md | 2 ++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/util/environment/README.i18n.yaml b/packages/util/environment/README.i18n.yaml index c7ad354478..ea1e025257 100644 --- a/packages/util/environment/README.i18n.yaml +++ b/packages/util/environment/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/environment/README.md -README.md: 526c7263106962cdbc19ec58c00b06e58849a258 -README.zh.md: 203b8252d2e96235ec083481ccafda129902cd38 +README.md: 1bb444bc217ce1a01fb98f954d6e1c2bbc3db957 +README.zh.md: a46adf0beeb0fb2069e198c99e4c00c2e8c09c6c diff --git a/packages/util/environment/README.md b/packages/util/environment/README.md index 526c726310..1bb444bc21 100644 --- a/packages/util/environment/README.md +++ b/packages/util/environment/README.md @@ -18,6 +18,8 @@ Values do also reach `process.env` — a user's `--config` tree and third-party **Omitting a layer is a refusal, not a demotion** — a caller that must never accept a layer leaves it out of the list, so no future reordering can let it back in. The provider adapters name all three, because the product trusts the project it runs in; the mechanism exists for the decisions where that is not true. +Names match the way the platform matches them: exactly on POSIX, case-insensitively on Windows. A case-sensitive lookup there would rank the wrong layer — a shell's `deepseek_api_key` and a project `.env`'s `DEEPSEEK_API_KEY` are one variable to the OS, and treating them as two would let the project win. + ```ts import type { Context } from 'cordis' import { environmentOf } from '@deepseek-ai/dsh-environment' diff --git a/packages/util/environment/README.zh.md b/packages/util/environment/README.zh.md index 203b8252d2..a46adf0bee 100644 --- a/packages/util/environment/README.zh.md +++ b/packages/util/environment/README.zh.md @@ -18,6 +18,8 @@ **省略某一层是拒绝,不是降级**——绝不能接受某一层的调用方直接不把它列进去,后续任何重新排序都无法让它回来。provider 适配器三层全列,因为产品信任它所运行的项目;该机制是为那些「并非如此」的决策准备的。 +变量名按平台自身的规则匹配:POSIX 上精确匹配,Windows 上不区分大小写。在 Windows 上做大小写敏感的查找会选错层——shell 里的 `deepseek_api_key` 与项目 `.env` 里的 `DEEPSEEK_API_KEY` 对操作系统而言是同一个变量,把它们当成两个就会让项目胜出。 + ```ts import type { Context } from 'cordis' import { environmentOf } from '@deepseek-ai/dsh-environment' From f50b60390c539a979ca69713ab92ae72682a4c8c Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 5 Aug 2026 12:49:05 +0800 Subject: [PATCH 10/27] docs: regenerate the module graph for the environment package `dsh-environment` and its consumer edges were missing from the generated graph. --- docs/module-graph.md | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index aae611eff5..344d1f7131 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -10,6 +10,7 @@ flowchart TD subgraph group_util["packages/util"] pkg_atomic_write["atomic-write"] pkg_brand["brand"] + pkg_environment["environment"] pkg_native_command["native-command"] pkg_paths["paths"] pkg_retention["retention"] @@ -275,6 +276,7 @@ flowchart TD end pkg_atomic_write --> pkg_invariants pkg_brand --> pkg_invariants + pkg_environment --> pkg_invariants pkg_native_command --> pkg_invariants pkg_paths --> pkg_invariants pkg_retention --> pkg_invariants @@ -345,11 +347,13 @@ flowchart TD pkg_typert_loader --> pkg_invariants pkg_typert_loader --> pkg_typert_registry pkg_llm_deepseek --> pkg_credentials + pkg_llm_deepseek --> pkg_environment pkg_llm_deepseek --> pkg_invariants pkg_llm_deepseek --> pkg_llm pkg_llm_deepseek --> pkg_settings pkg_llm_deepseek --> pkg_timeout pkg_llm_pi_ai --> pkg_credentials + pkg_llm_pi_ai --> pkg_environment pkg_llm_pi_ai --> pkg_invariants pkg_llm_pi_ai --> pkg_llm pkg_llm_pi_ai --> pkg_settings @@ -402,6 +406,7 @@ flowchart TD pkg_client_ui_workspace --> pkg_invariants pkg_credentials_local --> pkg_atomic_write pkg_credentials_local --> pkg_credentials + pkg_credentials_local --> pkg_environment pkg_credentials_local --> pkg_invariants pkg_credentials_local --> pkg_paths pkg_lsp --> pkg_brand @@ -435,8 +440,10 @@ flowchart TD pkg_web_fetch_local --> pkg_invariants pkg_web_fetch_local --> pkg_timeout pkg_web_fetch_local --> pkg_web + pkg_web_search_exa --> pkg_environment pkg_web_search_exa --> pkg_invariants pkg_web_search_exa --> pkg_web + pkg_web_search_perplexity --> pkg_environment pkg_web_search_perplexity --> pkg_invariants pkg_web_search_perplexity --> pkg_web pkg_spill --> pkg_brand @@ -449,6 +456,7 @@ flowchart TD pkg_llm_replay --> pkg_invariants pkg_llm_replay --> pkg_llm pkg_llm_replay --> pkg_session + pkg_app_boot --> pkg_environment pkg_app_boot --> pkg_invariants pkg_app_boot --> pkg_paths pkg_app_boot --> pkg_system_prompt @@ -520,6 +528,7 @@ flowchart TD pkg_skill_local --> pkg_skill pkg_web_search_deepseek --> pkg_agent pkg_web_search_deepseek --> pkg_credentials + pkg_web_search_deepseek --> pkg_environment pkg_web_search_deepseek --> pkg_invariants pkg_web_search_deepseek --> pkg_session pkg_web_search_deepseek --> pkg_web @@ -1078,6 +1087,7 @@ flowchart TD | [`invariants`](../packages/support/invariants) | `support` | — | | [`atomic-write`](../packages/util/atomic-write) | `util` | [`invariants`](../packages/support/invariants) | | [`brand`](../packages/util/brand) | `util` | [`invariants`](../packages/support/invariants) | +| [`environment`](../packages/util/environment) | `util` | [`invariants`](../packages/support/invariants) | | [`native-command`](../packages/util/native-command) | `util` | [`invariants`](../packages/support/invariants) | | [`paths`](../packages/util/paths) | `util` | [`invariants`](../packages/support/invariants) | | [`retention`](../packages/util/retention) | `util` | [`invariants`](../packages/support/invariants) | @@ -1119,8 +1129,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` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | -| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | +| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | +| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`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) | @@ -1131,7 +1141,7 @@ flowchart TD | [`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) | | [`client-ui-workspace`](../packages/client/ui-workspace) | `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) | +| [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`settings-local`](../packages/settings/settings-local) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`settings`](../packages/settings/settings) | @@ -1141,12 +1151,12 @@ flowchart TD | [`compact`](../packages/compact/compact) | `compact` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | `compact` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`web-fetch-local`](../packages/web/web-fetch-local) | `web` | [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | -| [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`invariants`](../packages/support/invariants), [`web`](../packages/web/web) | -| [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`invariants`](../packages/support/invariants), [`web`](../packages/web/web) | +| [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`web`](../packages/web/web) | +| [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`web`](../packages/web/web) | | [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`session-persistence`](../packages/session-persistence/session-persistence) | `session-persistence` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`llm-replay`](../packages/support/llm-replay) | `support` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`app-boot`](../packages/ui/app-boot) | `ui` | [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`system-prompt`](../packages/core/system-prompt) | +| [`app-boot`](../packages/ui/app-boot) | `ui` | [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`system-prompt`](../packages/core/system-prompt) | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | | [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | @@ -1163,7 +1173,7 @@ flowchart TD | [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | | [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | | [`skill-local`](../packages/skill/skill-local) | `skill` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`skill`](../packages/skill/skill) | -| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`web`](../packages/web/web) | +| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`web`](../packages/web/web) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/support/invariants), [`spill`](../packages/spill/spill) | | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl) | `session-persistence` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | From 69d8621e2e060ab158467809b47a0841a976ecbe Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 5 Aug 2026 13:20:56 +0800 Subject: [PATCH 11/27] test: close the per-file coverage gaps this PR opened MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The layered-env reader gained an unreadable-layer path, a default reporter, and two absent-layer arms with no cases; the credential store gained two error paths that must not be mistaken for an absent file. The platform arms and the `linePos` guard cannot be reached from a POSIX test run — the first is covered by the native Windows job, the second only satisfies an optional type that `prettyErrors` always fills — so both carry a v8 ignore naming why. --- .../credentials-local/src/index.ts | 2 + .../credentials-local/tests/local.spec.ts | 23 ++++ packages/settings/settings-local/src/index.ts | 1 + packages/ui/app-boot/tests/app-boot.spec.ts | 107 ++++++++++++++++++ packages/util/environment/src/index.ts | 1 + 5 files changed, 134 insertions(+) diff --git a/packages/credentials/credentials-local/src/index.ts b/packages/credentials/credentials-local/src/index.ts index a5024353c8..ea77458d12 100644 --- a/packages/credentials/credentials-local/src/index.ts +++ b/packages/credentials/credentials-local/src/index.ts @@ -101,6 +101,7 @@ const GROUP_OTHER_BITS = 0o077 * @throws when the file exists with group or other permission bits set. */ async function assertOwnerOnly(filename: string): Promise { + /* v8 ignore next -- native Windows coverage exercises the skip; POSIX covers the check */ if (process.platform === 'win32') return let mode: number try { @@ -130,6 +131,7 @@ function isENOENT(error: unknown): boolean { */ function describeYamlError(error: YAMLError): string { const at = error.linePos?.[0] + /* v8 ignore next -- `prettyErrors` populates linePos on every error; the guard answers its optional type */ const where = at === undefined ? '' : ` at line ${String(at.line)}, column ${String(at.col)}` return `${error.code}${where}` } diff --git a/packages/credentials/credentials-local/tests/local.spec.ts b/packages/credentials/credentials-local/tests/local.spec.ts index 7a8b8fdc17..43e42cd53f 100644 --- a/packages/credentials/credentials-local/tests/local.spec.ts +++ b/packages/credentials/credentials-local/tests/local.spec.ts @@ -179,6 +179,29 @@ describe('layer ladder', () => { .rejects.toThrow(/readable beyond its owner \(mode 644\)/) }) + it('propagates a permission check that fails for a reason other than absence', async () => { + const dir = await tempDir() + const notADirectory = join(dir, 'occupied') + await writeFile(notADirectory, 'a regular file\n') + // An absent document is an empty store, but a path that cannot be + // reached at all is a misconfiguration: the parent is a file, so the + // check fails with ENOTDIR rather than concluding "no credentials yet". + const ctx = new Context() + await expect(ctx.plugin(CredentialsLocal, { path: join(notADirectory, '.credentials.yaml'), watch: false })) + .rejects.toThrow(/ENOTDIR/) + }) + + it('propagates a read that fails for a reason other than absence', async () => { + const dir = await tempDir() + const path = join(dir, '.credentials.yaml') + // Owner-only, so the permission check passes, and unreadable as a file: + // the store is present but cannot be parsed, which must fail the launch + // rather than silently serve nothing. + await mkdir(path, { mode: 0o700 }) + const ctx = new Context() + await expect(ctx.plugin(CredentialsLocal, { path, watch: false })).rejects.toThrow(/EISDIR/) + }) + it('lets only the inherited environment shadow the store, read-only', async () => { const dir = await tempDir() const path = join(dir, '.credentials.yaml') diff --git a/packages/settings/settings-local/src/index.ts b/packages/settings/settings-local/src/index.ts index d713083c20..142d7935bd 100644 --- a/packages/settings/settings-local/src/index.ts +++ b/packages/settings/settings-local/src/index.ts @@ -250,6 +250,7 @@ export class SettingsLocal extends Settings { throw new Error(`settings-local: invalid document at ${this.spec.filename}: ${ document.errors.map((error) => { const at = error.linePos?.[0] + /* v8 ignore next -- `prettyErrors` populates linePos on every error; the guard answers its optional type */ return `${error.code}${at === undefined ? '' : ` at line ${String(at.line)}, column ${String(at.col)}`}` }).join('; ')}`) } diff --git a/packages/ui/app-boot/tests/app-boot.spec.ts b/packages/ui/app-boot/tests/app-boot.spec.ts index fba1ad1993..4d44c780c7 100644 --- a/packages/ui/app-boot/tests/app-boot.spec.ts +++ b/packages/ui/app-boot/tests/app-boot.spec.ts @@ -190,6 +190,113 @@ describe('loadLayeredEnv', () => { vi.unstubAllEnvs() } }) + + it('warns and continues when a layer exists but cannot be read', () => { + const home = tmp() + const project = tmp() + // A directory named `.env` is present-but-unreadable (EISDIR): unlike an + // absent file, it is a real misconfiguration, so it is reported rather + // than passed over in silence — and the other layers still load. + mkdirSync(join(home, '.env')) + writeFileSync(join(project, '.env'), `${NAMES[2]}=project-only\n`) + clear() + vi.stubEnv('DSH_HOME', home) + const warn = vi.fn() + try { + const snapshot = loadLayeredEnv(NAME, project, warn) + expect(warn).toHaveBeenCalledWith(expect.stringContaining(`${NAME}: failed to load .env`)) + expect(snapshot.layers).toEqual([ + { source: 'process' }, + { source: 'project-env', path: join(project, '.env') }, + ]) + expect(process.env[NAMES[2]]).toBe('project-only') + } finally { + clear() + vi.unstubAllEnvs() + } + }) + + it('reports to stderr when the caller supplies no reporter', () => { + const home = tmp() + const project = tmp() + mkdirSync(join(home, '.env')) + writeFileSync(join(project, '.env'), `${NAMES[2]}=project-only\n`) + clear() + vi.stubEnv('DSH_HOME', home) + const write = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + try { + const snapshot = loadLayeredEnv(NAME, project) + expect(write).toHaveBeenCalledWith(expect.stringContaining(`${NAME}: failed to load .env`)) + expect(snapshot.layers).toEqual([ + { source: 'process' }, + { source: 'project-env', path: join(project, '.env') }, + ]) + expect(process.env[NAMES[2]]).toBe('project-only') + } finally { + write.mockRestore() + clear() + vi.unstubAllEnvs() + } + }) + + it('passes over an absent layer without reporting it', () => { + const home = tmp() + const project = tmp() + writeFileSync(join(project, '.env'), `${NAMES[2]}=project-only\n`) + clear() + vi.stubEnv('DSH_HOME', home) + const warn = vi.fn() + try { + // No user `.env` exists, which is ordinary rather than a fault: the + // layer is simply absent, and nothing is reported. + const snapshot = loadLayeredEnv(NAME, project, warn) + expect(warn).not.toHaveBeenCalled() + expect(snapshot.layers).toEqual([ + { source: 'process' }, + { source: 'project-env', path: join(project, '.env') }, + ]) + } finally { + clear() + vi.unstubAllEnvs() + } + }) + + it('carries only the inherited environment when neither file exists', () => { + const home = tmp() + const project = tmp() + clear() + vi.stubEnv('DSH_HOME', home) + vi.stubEnv('APP_BOOT_LAYERED_INHERITED', 'inherited') + try { + const snapshot = loadLayeredEnv(NAME, project, vi.fn()) + expect(snapshot.layers).toEqual([{ source: 'process' }]) + expect(snapshot.get('APP_BOOT_LAYERED_INHERITED')).toEqual({ value: 'inherited', source: 'process' }) + } finally { + clear() + vi.unstubAllEnvs() + } + }) + + it('reads a harness home that is also the invocation directory exactly once', () => { + const both = tmp() + writeFileSync(join(both, '.env'), `${NAMES[2]}=one-file\n`) + clear() + vi.stubEnv('DSH_HOME', both) + try { + // One file cannot be two layers. It is the project layer, because that + // is the more trusted of the two — reading it twice would otherwise + // put the same path at two different ranks. + const snapshot = loadLayeredEnv(NAME, both, vi.fn()) + expect(snapshot.layers).toEqual([ + { source: 'process' }, + { source: 'project-env', path: join(both, '.env') }, + ]) + expect(snapshot.get(NAMES[2])).toEqual({ value: 'one-file', source: 'project-env', path: join(both, '.env') }) + } finally { + clear() + vi.unstubAllEnvs() + } + }) }) describe('installFailLoud', () => { diff --git a/packages/util/environment/src/index.ts b/packages/util/environment/src/index.ts index 11014f64b5..f35e32f9c5 100644 --- a/packages/util/environment/src/index.ts +++ b/packages/util/environment/src/index.ts @@ -76,6 +76,7 @@ export interface EnvironmentSnapshot { * @returns the key to store and look up by. */ function lookupKey(name: string): string { + /* v8 ignore next -- native Windows coverage exercises the folding arm; POSIX covers the exact one */ return process.platform === 'win32' ? name.toUpperCase() : name } From e1d226c4affa5137cb253a40b7a5f25aa7279e59 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 5 Aug 2026 13:23:19 +0800 Subject: [PATCH 12/27] test: point the last two credential stores at the YAML document The e2e store and the web-search store still named a `.env` path; the e2e one also wrote dotenv syntax, which the YAML document rejects. That path now names the ordinary environment layer, so a test pointing the credential store at it asserts the distinction this PR removes. --- packages/llm/llm-deepseek/tests/adapter.e2e.ts | 6 ++++-- packages/web/web-search-deepseek/tests/deepseek.spec.ts | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/llm/llm-deepseek/tests/adapter.e2e.ts b/packages/llm/llm-deepseek/tests/adapter.e2e.ts index 5468cd8d9f..97ae629001 100644 --- a/packages/llm/llm-deepseek/tests/adapter.e2e.ts +++ b/packages/llm/llm-deepseek/tests/adapter.e2e.ts @@ -62,14 +62,16 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', () if (key === undefined) throw new Error('e2e ran without DEEPSEEK_API_KEY') const dir = await mkdtemp(join(tmpdir(), 'dsh-e2e-credentials-')) try { - await writeFile(join(dir, '.env'), `DEEPSEEK_API_KEY=${key}\n`, { mode: 0o600 }) + // JSON.stringify quotes the value: YAML is a JSON superset, so a real + // key survives whatever characters it happens to carry. + await writeFile(join(dir, '.credentials.yaml'), `DEEPSEEK_API_KEY: ${JSON.stringify(key)}\n`, { mode: 0o600 }) // Scrub the ambient variable so only the credential seam can supply the // key: this request proves the per-request resolution path end to end. vi.stubEnv('DEEPSEEK_API_KEY', '') const ctx = new Context() contexts.push(ctx) await ctx.plugin(LlmService) - await ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false }) + await ctx.plugin(CredentialsLocal, { path: join(dir, '.credentials.yaml'), watch: false }) await ctx.plugin(LlmDeepSeek, {}) const result = await assemble(ctx, { diff --git a/packages/web/web-search-deepseek/tests/deepseek.spec.ts b/packages/web/web-search-deepseek/tests/deepseek.spec.ts index 7990a96a9b..23c2d2c237 100644 --- a/packages/web/web-search-deepseek/tests/deepseek.spec.ts +++ b/packages/web/web-search-deepseek/tests/deepseek.spec.ts @@ -455,7 +455,7 @@ describe('web-search-deepseek plugin registration', () => { const ctx = new Context() try { await ctx.plugin(WebService, { searchProvider: DEEPSEEK_PROVIDER_ID }) - await ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false }) + await ctx.plugin(CredentialsLocal, { path: join(dir, '.credentials.yaml'), watch: false }) await ctx.plugin(deepseekPlugin, { baseURL: 'https://api.deepseek.test/anthropic/v1' }) await expect(ctx.web.search({ query: 'missing' })) From 70cf4a147145a8de4714140dd0e2d7b33c1d04f3 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 5 Aug 2026 18:26:07 +0800 Subject: [PATCH 13/27] test(web): follow master's icon-only add-provider button The merge restored the icon variant of the Models add button; its accessible name no longer carries the `+` text prefix. --- apps/web/tests/models-settings.e2e.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts index 33e27628b0..694f268c59 100644 --- a/apps/web/tests/models-settings.e2e.ts +++ b/apps/web/tests/models-settings.e2e.ts @@ -58,7 +58,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => { await dialog.getByText('填入各提供方的 API 密钥即可使用其模型。').waitFor({ timeout: 10_000 }) // The dormant pi-ai adapter contributes its whole installed catalog; no // provider is configured yet, so the page is one add button. - const add = dialog.getByRole('button', { name: '+ 添加提供方' }) + const add = dialog.getByRole('button', { name: '添加提供方' }) await add.waitFor({ timeout: 10_000 }) // The button enables once the dormant catalog lands in the join. await expect.poll(async () => add.isEnabled(), { timeout: 10_000 }).toBe(true) From d8d487236f656869428250cc0895afa859001e4b Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 5 Aug 2026 18:36:32 +0800 Subject: [PATCH 14/27] test(cli): mount the never-dispose plugin through --config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The headless shutdown probe needs a plugin that refuses to dispose, so the second Ctrl+C has something to force past. Writing it to the Harness home stopped working when the personal composition layer was deleted: nothing is discovered there, the plugin never mounted, and the first signal drained cleanly — leaving the second PTY action to time out. --- apps/cli/tests/headless-shutdown.e2e.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/cli/tests/headless-shutdown.e2e.ts b/apps/cli/tests/headless-shutdown.e2e.ts index 81089b3598..4ca8fdc0e5 100644 --- a/apps/cli/tests/headless-shutdown.e2e.ts +++ b/apps/cli/tests/headless-shutdown.e2e.ts @@ -66,7 +66,10 @@ async function runHeadlessPtySmoke(): Promise { try { const home = join(cwd, '.dsh') await mkdir(home, { recursive: true }) - await writeFile(join(home, 'config.yaml'), [ + // The overlay is named, not discovered: nothing is auto-loaded from the + // Harness home, and `-p` takes `--config` for exactly this reason. + const overlay = join(cwd, 'never-dispose.cordis.yml') + await writeFile(overlay, [ '- insert:', ' - id: never-dispose', ` name: '${neverDisposePlugin}'`, @@ -74,7 +77,7 @@ async function runHeadlessPtySmoke(): Promise { ].join('\n')) const launch = resolveExampleLaunch({ srcBin: dshBinScript, - configArgs: ['-p', 'never complete'], + configArgs: ['-p', 'never complete', '--config', overlay], tsconfigPath, env: { DSH_HOME: home, From 48fd9b70ea99af5974314590a3d283fee2a5182e Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 6 Aug 2026 21:43:40 +0800 Subject: [PATCH 15/27] docs: drop the notes for changes master now owns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The profile-json entry and the personal composition layer were both settled on master by its profile restructure — the first removed with `app-cli-entry.ts`, the second deliberately restored as `$DSH_HOME/cordis.patch.yml`. Neither is this branch's change any more, so the notes claiming them go, and the prose they edited returns to master's. --- ...tree-boot-and-transport-layering.i18n.yaml | 4 +-- ...config-tree-boot-and-transport-layering.md | 4 +-- ...fig-tree-boot-and-transport-layering.zh.md | 4 +-- ...-08-04-remove-profile-json-entry.i18n.yaml | 6 ---- .../2026-08-04-remove-profile-json-entry.md | 32 ------------------- ...2026-08-04-remove-profile-json-entry.zh.md | 32 ------------------- 6 files changed, 6 insertions(+), 76 deletions(-) delete mode 100644 .agents/notes/implemented/simplification/2026-08-04-remove-profile-json-entry.i18n.yaml delete mode 100644 .agents/notes/implemented/simplification/2026-08-04-remove-profile-json-entry.md delete mode 100644 .agents/notes/implemented/simplification/2026-08-04-remove-profile-json-entry.zh.md diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml index a32146cbc6..2c1f309a79 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.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 .agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md -2026-07-24-web-config-tree-boot-and-transport-layering.md: e4dd8b50fe565deecb6e64d307305c66af50c001 -2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 17d0cf6c7169fd38f9b5abd0650ec2377eaf5865 +2026-07-24-web-config-tree-boot-and-transport-layering.md: 88f94b1f58ae7a3451c7772f4a9ff7d6564254c0 +2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 5f03dfbb8e5eaeeb52076584721e70ea66a292df diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md index e4dd8b50fe..88f94b1f58 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md @@ -16,7 +16,7 @@ English | [中文](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md) **Boot glue is a class pair.** `AppCLIEntry` (apps/cli) and `AppWebEntry` (the shell kernel) hold only what must exist independently of cordis: argv facts, the composed patch set, the parsed boot manifest, the module system instance, loading-page handles — everything else lives in plugins. `AppCLIEntry.run()` is three stages: layered env (ambient > cwd `.env` > `$DSH_HOME/.env`, closing the defect above) → patch composition → Loader include boot plus the activation audit. `AppWebEntry.run()` mirrors it browser-side: parse `window.__DSH_BOOT__` into a `BootManifest` (two views: npm-package rows for the module table, cordis-plugin rows for entry composition; malformed wire throws), build the module system, render the loading page, prefetch the `immediately` tier in parallel with Context/Loader setup, **await the prefetch before creating entries** (materialization is `tree.import`'s synchronous require, unprotected by fiber inject waiting; cross-package require edges such as i18n → runtime/client need every immediately-tier factory registered first — an empirically found 10–25% boot race otherwise), adopt the modules entry, create the graph rows, settle, sweep. -**Config sources have one declaration place each.** yml static values are engineering defaults; CLI flags map onto the `webserver` row; env values enter through yml `!!js` expressions. This decision also introduced a profile json (`./.dsh-tmp-profile/config.json`) as the user-config source, mapped through a static `PROFILE_MAPPINGS` table onto target rows; it never gained a writer and is [now removed](../simplification/2026-08-04-remove-profile-json-entry.md), leaving flags and the assembly fact below as the only patch sources. Patches replace a row's config wholesale, so the entry class re-reads the yml row's static values (bypass parse) and merges overrides on top. The resolved frontend `distIndex` rides the same patch channel — an assembly fact, not user config. +**Config sources have one declaration place each.** yml static values are engineering defaults; the profile json (`./.dsh-tmp-profile/config.json`, read-only, never created, cwd-anchored until the `$DSH_HOME` migration) is user config mapped through a static `PROFILE_MAPPINGS` table onto target rows (`provider`/`model` → the `api-gateway` row, `persistenceRoot` → the jsonl row); CLI flags map onto the `webserver` row with a field set disjoint from the json's; env values enter through yml `!!js` expressions, never through the mapping table. Patches replace a row's config wholesale, so the entry class re-reads the yml row's static values (bypass parse) and merges overrides on top. An unmapped json key fails loud. The resolved frontend `distIndex` rides the same patch channel — an assembly fact, not user config. **The transport splits five ways.** `dsh-host-apiproxy` upgraded to the gateway plugin (`api-gateway` row): default-exports `ApiProxyService`, config `{provider, model}`, provides `ctx.apiProxy`, transport-agnostic and registers no routes — `createApiProxy` moved here from the retired runtime package. `dsh-host-webserver` shrank to a plain route-registration plugin: `HttpServerService` provides `ctx.httpServer` (`register(route) → disposer` with duplicate-pattern throw, `tapIndex` transforms applied in registration order, `port`), listens on activation, per-request failures answer 400 and log without exiting, and knows no harness concepts. The connection node half owns the binding: it injects both services and registers `toFetchHandler(ctx.apiProxy)` under the `/api` prefix — future IPC carriers swap connection's transport while the gateway stays untouched. The modules node half (`ClientModuleHostService`, providing `ctx.clientModuleHost`) owns the graph: incremental per-package scanning (no full-rescan code path — `internal/plugin` marks the fiber's entry name dirty, a flush reconciles each name against live entries, package metadata including negative verdicts is cached forever, re-hashing is reachable only through `rebuilt(id)`), the bundle route, the index tap, and `onRebuilt`/`onGraphChanged` notification. The hmr node half owns dev reload: `fs.watchFile` stat-polling driven by `onGraphChanged` membership, and the `/plugins/events` SSE route. @@ -25,7 +25,7 @@ English | [中文](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md) ## Consequences - Recomposing a web deployment is a yml/patch edit; the retired pieces (`mountWebPlugins`, `CLIENT_PACKAGES`, `createHostWebPluginRegistry`, `startWebServer`, the webserver's graph/SSE/api knowledge) are deleted. -- Headless boots the same composition through the same entry (landed in the stacked follow-up): port 0 is its only surface difference, the model face gains `ask_user_question`/workspace context/model titles per the unification ruling, and `bootHost`/`startHost` retired with the `dsh-host-runtime` package. IPC carriers remain a recorded deferral; the profile write path and the `$DSH_HOME` profile relocation were dropped with the profile json itself. +- Headless boots the same composition through the same entry (landed in the stacked follow-up): port 0 is its only surface difference, the model face gains `ask_user_question`/workspace context/model titles per the unification ruling, and `bootHost`/`startHost` retired with the `dsh-host-runtime` package. The profile write path, the `$DSH_HOME` profile relocation, and IPC carriers remain recorded deferrals. - A TypeScript pitfall worth remembering: a `declare module 'cordis'` augmentation in a file with **no cordis import** is demoted to a standalone module declaration and silently shatters the program-wide `Context` merge (`ctx.on`/`ctx.effect` vanish across the program). Anchor with `import type {} from 'cordis'`. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md index 17d0cf6c71..5f03dfbb8e 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md @@ -16,7 +16,7 @@ Status: implemented **boot 胶水由两个类组成。** `AppCLIEntry`(apps/cli)与 `AppWebEntry`(壳内核)只持有那些必须独立于 cordis、提前存在的东西:argv 事实、合成的 patch 集、解析出的 boot manifest(元数据清单)、模块系统实例、loading 页句柄——其余一律进插件。`AppCLIEntry.run()` 三段:分层 env(ambient > cwd `.env` > `$DSH_HOME/.env`,顺手关掉上述缺陷)→ patch 合成 → Loader include boot 加 activation audit。`AppWebEntry.run()` 在浏览器侧镜像它:把 `window.__DSH_BOOT__` 解析成 `BootManifest`(双视角:npm 包行给模块表、cordis 插件行给 entry 组合;畸形 wire 大声抛)、建模块系统、渲染 loading 页、immediately 层预取与 Context/Loader 准备并行、**create entry 之前等预取齐**(物化是 `tree.import` 的同步 require,不受 fiber inject 等待保护;i18n → runtime/client 这类跨包 require 边要求 immediately 层工厂全部注册完——否则有实测 10–25% 的 boot 竞态)、收编 modules entry、逐一创建图行、settle、sweep。 -**每个配置源有唯一声明位置。** yml 静态值是工程默认;CLI(命令行界面)flags 映射到 `webserver` 行;env 值经 yml `!!js` 表达式进入。本决策当时还引入了 profile json(`./.dsh-tmp-profile/config.json`)作为用户配置源,经静态 `PROFILE_MAPPINGS` 表映射到目标行;它始终没有获得写入方,[现已删除](../simplification/2026-08-04-remove-profile-json-entry.md),patch 来源只剩 flags 与下述装配事实。patch 整体替换行 config,故 entry 类旁路 parse 重读 yml 行静态值再叠加覆盖。解析出的前端 `distIndex` 走同一 patch 通道——装配事实,不是用户配置。 +**每个配置源有唯一声明位置。** yml 静态值是工程默认;profile json(`./.dsh-tmp-profile/config.json`,只读、绝不创建、暂锚 cwd 直至 `$DSH_HOME` 迁移)是用户配置,经静态 `PROFILE_MAPPINGS` 表映射到目标行(`provider`/`model` → `api-gateway` 行,`persistenceRoot` → jsonl 行);CLI(命令行界面)flags 映射到 `webserver` 行、字段集与 json 不相交;env 值经 yml `!!js` 表达式进入,绝不进映射表。patch 整体替换行 config,故 entry 类旁路 parse 重读 yml 行静态值再叠加覆盖。未映射的 json 键 fail loud。解析出的前端 `distIndex` 走同一 patch 通道——装配事实,不是用户配置。 **传输五分。** `dsh-host-apiproxy` 升格网关插件(`api-gateway` 行):默认导出 `ApiProxyService`,config `{provider, model}`,provide `ctx.apiProxy`,传输无关、不注册路由——`createApiProxy` 从已退役的运行时包迁入。`dsh-host-webserver` 缩成朴素路由注册插件:`HttpServerService` provide `ctx.httpServer`(`register(route) → disposer`、重复 pattern 即抛、`tapIndex` 按注册序应用、`port`),激活即 listen,单请求失败答 400 并记日志,不退出进程,不认识任何 harness 概念。connection node 半拥有绑定:inject 两个服务,把 `toFetchHandler(ctx.apiProxy)` 注册在 `/api` 前缀下——将来 IPC 载体只换 connection 的传输,网关零改动。modules node 半(`ClientModuleHostService`,provide `ctx.clientModuleHost`)拥有图:单包增量扫描(无全量重扫路径——`internal/plugin` 把 fiber 的 entry 名标脏,flush 逐名对账 live entries,包括否定结论在内的包元数据会永久缓存,重哈希唯一入口 `rebuilt(id)`)、bundle 路由、index tap、`onRebuilt`/`onGraphChanged` 通知。HMR node 半拥有开发期重载:`fs.watchFile` stat 轮询、watch 集合跟随 `onGraphChanged`、`/plugins/events` SSE 路由。 @@ -25,7 +25,7 @@ Status: implemented ## 后果 - 重组一个 web 部署 = 改 yml/patch;退役件(`mountWebPlugins`、`CLIENT_PACKAGES`、`createHostWebPluginRegistry`、`startWebServer`、webserver 的图/SSE/api 知识)全部删除。 -- headless 已在 stacked 后续轮迁入同一组合同一入口:唯一面差异是 port 0,模型面按统一裁决获得 `ask_user_question`/workspace context/模型标题,`bootHost`/`startHost` 随 `dsh-host-runtime` 包退役。IPC 载体仍为挂账项;profile 写入路径与 profile 迁 `$DSH_HOME` 已随 profile json 本身一并放弃。 +- headless 已在 stacked 后续轮迁入同一组合同一入口:唯一面差异是 port 0,模型面按统一裁决获得 `ask_user_question`/workspace context/模型标题,`bootHost`/`startHost` 随 `dsh-host-runtime` 包退役。profile 写入路径、profile 迁 `$DSH_HOME`、IPC 载体仍为挂账项。 - 一个值得记住的 TypeScript 坑:`declare module 'cordis'` augmentation 所在文件若**没有任何 cordis import**,会被降级成独立 module declaration,无声打散全程序的 `Context` merge(`ctx.on`/`ctx.effect` 全程序消失)。用 `import type {} from 'cordis'` 锚定。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/simplification/2026-08-04-remove-profile-json-entry.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-04-remove-profile-json-entry.i18n.yaml deleted file mode 100644 index 5059240ce9..0000000000 --- a/.agents/notes/implemented/simplification/2026-08-04-remove-profile-json-entry.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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/simplification/2026-08-04-remove-profile-json-entry.md -2026-08-04-remove-profile-json-entry.md: 90d90adc8c4a6828f3ce49253150d09527a8304a -2026-08-04-remove-profile-json-entry.zh.md: 60646a0ffc76ec967fef57f54ff0865b3c842754 diff --git a/.agents/notes/implemented/simplification/2026-08-04-remove-profile-json-entry.md b/.agents/notes/implemented/simplification/2026-08-04-remove-profile-json-entry.md deleted file mode 100644 index 90d90adc8c..0000000000 --- a/.agents/notes/implemented/simplification/2026-08-04-remove-profile-json-entry.md +++ /dev/null @@ -1,32 +0,0 @@ -# Agent Note: Removing the profile-json config entry - -Status: implemented - -English | [中文](2026-08-04-remove-profile-json-entry.zh.md) - -## Problem - -`./.dsh-tmp-profile/config.json` was the user-configuration plane of the [web config-tree boot](../architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md): a read-only JSON object under the invoking directory, mapped by a static `PROFILE_MAPPINGS` table onto three fields across two rows. Its write path and its relocation to the Harness home were recorded there as deferrals, and neither arrived. Nothing in the product ever created or edited the file, no test exercised it, and no user documentation named it — the format existed only as a reader. - -Meanwhile the fields it mapped acquired owners elsewhere. `provider` and `model` are the api-gateway's default route for created and resumed agents, which a session's own picker overrides per agent; `persistenceRoot` is an assembly fact of the shipped composition. Typed user preferences became `$DSH_HOME/settings.yaml` under the [user-settings seam](../architecture/2026-07-28-user-settings-seam.md). What remained was a third user-configuration format, anchored to the invoking directory and behind a hand-maintained mapping table, that nothing wrote. - -## Decision - -`PROFILE_DIR`, `PROFILE_FILE`, `ProfileMapping`, `PROFILE_MAPPINGS`, and `readProfile()` are deleted along with the patch source that consumed them. `AppCLIEntry` composes its patches from CLI flags and the resolved frontend `distIndex` only; the layers around it — shipped base, surface overlay, and the `--config` overlay — are unchanged. - -A `.dsh-tmp-profile/config.json` on disk is now ignored completely. There is no migration, no replacement format, and no deprecation diagnostic: the file never had a producer, so there is no installed base to carry forward, and the [pre-release stance](../../../../AGENTS.md) rejects compatibility shims. - -## Alternatives considered - -**Keep the reader until typed settings own `provider`/`model`.** Rejected because the gap is not real: with no writer, the file gave users no way to pin a default route either, so keeping it preserves an unproduced format rather than a capability. - -**Relocate it to `$DSH_HOME`, the deferral the original note recorded.** Rejected because that deferral assumed the write path would arrive with it. Moving a file nothing writes only moves the dead entry, and the Harness home already has an owner for typed user preferences. - -**Report the file through a deprecation diagnostic when it exists.** Rejected because a diagnostic for a format the product never produced would advertise it to users who have never seen it. - -## Consequences - -- Given up: no file-based way to pin `provider`, `model`, or `persistenceRoot` without editing yml or passing `--config`. A persistent default route needs a typed settings namespace owned by whoever creates sessions; `persistenceRoot` stays an assembly fact. -- Bought: one fewer user-configuration format, one less input anchored to the invoking directory, and a patch composition whose only remaining sources are CLI flags and an assembly fact — the fail-loud mapping table goes with it. -- The [web config-tree boot note](../architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md) is only partially superseded: its composition, boot-glue, transport, and export decisions stand. Both notes stay cross-linked, and its profile facts were rewritten in place. -- Absence is verified by repo-wide search: `.dsh-tmp-profile`, `PROFILE_MAPPINGS`, and `readProfile` have no remaining match. diff --git a/.agents/notes/implemented/simplification/2026-08-04-remove-profile-json-entry.zh.md b/.agents/notes/implemented/simplification/2026-08-04-remove-profile-json-entry.zh.md deleted file mode 100644 index 60646a0ffc..0000000000 --- a/.agents/notes/implemented/simplification/2026-08-04-remove-profile-json-entry.zh.md +++ /dev/null @@ -1,32 +0,0 @@ -# Agent Note: 删除 profile-json 配置入口 - -Status: implemented - -[English](2026-08-04-remove-profile-json-entry.md) | 中文 - -## Problem - -`./.dsh-tmp-profile/config.json` 曾是 [web 配置树启动](../architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md)的用户配置面:调用目录下的一个只读 JSON 对象,经静态 `PROFILE_MAPPINGS` 表映射到两个行上的三个字段。它的写路径以及迁往 Harness home 的计划都记在那条 Note 里作为延后项,两者都没有落地。产品中从未有任何代码创建或编辑该文件,没有测试覆盖它,也没有用户文档提到它——这个格式只存在读取方。 - -与此同时,它映射的字段各自有了别处的归属。`provider` 与 `model` 是 api-gateway 为新建和恢复的 agent 提供的默认路由,会话自己的选择器可按 agent 覆盖它;`persistenceRoot` 是交付组合的装配事实。类型化的用户偏好则由 [user-settings seam](../architecture/2026-07-28-user-settings-seam.md) 下的 `$DSH_HOME/settings.yaml` 承接。剩下的只是第三个用户配置格式:锚定在调用目录、藏在一张手工维护的映射表后面,而且没有任何东西写它。 - -## Decision - -`PROFILE_DIR`、`PROFILE_FILE`、`ProfileMapping`、`PROFILE_MAPPINGS` 和 `readProfile()` 连同消费它们的那个 patch 来源一并删除。`AppCLIEntry` 现在只从 CLI 标志和解析出的前端 `distIndex` 合成 patch;它周围的各层——交付基座、surface overlay、以及 `--config` overlay——保持不变。 - -磁盘上的 `.dsh-tmp-profile/config.json` 现在被完全忽略。没有迁移、没有替代格式、也没有弃用诊断:该文件从来没有生产方,因此不存在需要承接的存量,而[未发布阶段的立场](../../../../AGENTS.md)拒绝兼容垫片。 - -## Alternatives considered - -**保留读取方,直到类型化 settings 接管 `provider`/`model`。** 否决,因为这个缺口并不真实存在:既然没有写入方,该文件同样没有给用户任何钉住默认路由的途径,保留它保住的是一个无人生产的格式,而不是一项能力。 - -**按原 Note 记录的延后项,把它迁到 `$DSH_HOME`。** 否决,因为那条延后项的前提是写路径会随之到来。搬动一个没人写的文件只是搬动了这个死入口,而 Harness home 已经有了类型化用户偏好的归属者。 - -**文件存在时通过弃用诊断报告它。** 否决,因为为一个产品从未生产过的格式给出诊断,等于向从没见过它的用户宣传它。 - -## Consequences - -- 放弃的:不再有基于文件、无需编辑 yml 或传 `--config` 就能钉住 `provider`、`model` 或 `persistenceRoot` 的途径。持久的默认路由需要一个由会话创建方拥有的类型化 settings namespace;`persistenceRoot` 仍是装配事实。 -- 换来的:少一个用户配置格式,少一个锚定在调用目录的输入,以及一处仅剩 CLI 标志与装配事实两个来源的 patch 合成——那张 fail-loud 映射表随之消失。 -- [web 配置树启动 Note](../architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md) 只被部分取代:它关于组合、启动胶水、传输与导出的决策仍然成立。两条 Note 保持互链,其中与 profile 相关的事实已就地改写。 -- 缺席由全仓搜索验证:`.dsh-tmp-profile`、`PROFILE_MAPPINGS` 与 `readProfile` 均无残留匹配。 From 55fca161e62ffd7374a823f84cc9b3483e42c31f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 7 Aug 2026 21:05:04 +0800 Subject: [PATCH 16/27] cleanup(config): remove textual process env audit The gate treated a literal process.env substring search as repository-wide source-ownership enforcement. It missed equivalent syntax while matching comments and strings, so the allowlist projected a security guarantee the implementation could not provide. Remove the scanner and its allowlist. Keep the independently useful shipped-config inline tripwire, and narrow both the module contract and bilingual Agent Note to its actual source-shape claim. --- ...4-configuration-source-ownership.i18n.yaml | 4 +- ...26-08-04-configuration-source-ownership.md | 2 +- ...08-04-configuration-source-ownership.zh.md | 2 +- scripts/verify-config-source-ownership.ts | 88 ++----------------- 4 files changed, 10 insertions(+), 86 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml index 4288bcae58..38d2409b9d 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.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 .agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md -2026-08-04-configuration-source-ownership.md: 45ac032db0b60e0c8ce5a8c96ad2cf9cd847e14a -2026-08-04-configuration-source-ownership.zh.md: e835325b0d87410e6513f08cf0777a1713deb8cd +2026-08-04-configuration-source-ownership.md: e06dbc85f2307fa8a50fba13000f42306d69d9bf +2026-08-04-configuration-source-ownership.zh.md: 6c6a128f1279a271f583e0bf4bcd27d0e5b81162 diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md index 45ac032db0..e06dbc85f2 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md @@ -48,7 +48,7 @@ The line is that these take effect with no user action, before any turn, outside **`packages/util/environment` owns the snapshot**, deliberately as a utility rather than a three-package capability seam. The snapshot is frozen before Cordis starts and injected once by the launcher, so there is no runtime implementation to swap; consumers need types and pure functions, which a `util/` package gives them without depending on a UI package. `environmentOf(ctx)` returns the launcher's snapshot, or the inherited environment as the only layer — an SDK host or bare `cordis.yml` discovered no files, so its single layer really is what it was launched with, and the same trusted lookups keep working there unchanged. -**`verify-config-source-ownership`** keeps both rules: no unregistered `process.env` read under `packages/*/*/src` (26 allowlisted, each with the reason it is a process fact), and no `apiKey`/`baseURL`/`headers` inlined from the environment in shipped Cordis configuration. Removing those inlines is what makes the deployment tier meaningful — with the shipped tree silent on `baseURL`, a present value means a human or deployment set it. +**`verify-config-source-ownership`** is a narrow tripwire for the ordinary single-line form of an `apiKey`/`baseURL`/`headers` environment inline in shipped Cordis configuration. Removing those inlines is what makes the deployment tier meaningful — with the shipped tree silent on `baseURL`, a present value means a human or deployment set it. Adapters own actual resolution; the gate makes no repository-wide claim about `process.env` access. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md index e835325b0d..6c6a128f12 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md @@ -50,7 +50,7 @@ inherited process environment (read-only, wins) **`packages/util/environment` 拥有该快照**,刻意做成 utility 而不是三包能力 seam。快照在 Cordis 启动前就冻结,并由启动器一次性注入,因此不存在需要切换的运行时实现;消费方需要的只是类型和纯函数,而 `util/` 包能提供这些且不必依赖 UI 包。`environmentOf(ctx)` 返回启动器的快照,或者返回只含继承环境的那一层——SDK 宿主或裸 `cordis.yml` 从未发现过任何文件,它那唯一一层确实就是它被启动时的环境,因此同样的受信查询在那里原样继续工作。 -**`verify-config-source-ownership`** 守住这两条规则:`packages/*/*/src` 下没有未登记的 `process.env` 读取(26 处在 allowlist 中,各自写明它为何是进程事实),以及已交付 Cordis 配置中不得从环境内联 `apiKey`/`baseURL`/`headers`。删除这些内联正是「部署层」得以成立的原因——已交付配置树对 `baseURL` 保持沉默之后,「有值」就意味着「人或部署设过它」。 +**`verify-config-source-ownership`** 仅作为一道窄门禁,检查已交付 Cordis 配置中从环境内联 `apiKey`/`baseURL`/`headers` 的普通单行写法。删除这些内联正是「部署层」得以成立的原因——已交付配置树对 `baseURL` 保持沉默之后,「有值」就意味着「人或部署设过它」。实际解析由适配器负责;该门禁不声称覆盖仓库范围内的 `process.env` 访问。 ## Consequences diff --git a/scripts/verify-config-source-ownership.ts b/scripts/verify-config-source-ownership.ts index 1abf269b83..ffc849bba3 100644 --- a/scripts/verify-config-source-ownership.ts +++ b/scripts/verify-config-source-ownership.ts @@ -1,20 +1,8 @@ /** - * Gate: every user-facing value has one owner, and no shipped file smuggles a - * second one in. - * - * Two rules, both about the same failure — a value reaching the harness - * through a path nobody ranked: - * - * 1. Production package source does not read `process.env` directly. A - * credential belongs to `ctx.credentials`, a user-configurable value to the - * environment snapshot plus its owner's resolve step, and a real - * process-launch fact to the app bootstrap. Each remaining read is listed - * below with the reason it is one of those. - * 2. Shipped Cordis configuration does not inline a credential or an endpoint - * from the environment. Doing so re-creates the layer the snapshot exists - * to rank: `apiKey: !!js process.env.X` and `baseURL: !!js process.env.X` - * bypass both the credential seam and the endpoint ladder, and a project - * file could then decide where a key is sent. + * Gate: shipped Cordis configuration does not use the ordinary inline form + * for a credential or endpoint from the environment. This narrow source-shape + * lint prevents checked-in composition from bypassing the credential seam and + * endpoint ladder; adapters remain responsible for actual value resolution. * @module scripts/verify-config-source-ownership */ @@ -23,58 +11,6 @@ import { resolve, sep } from 'node:path' const ROOT = resolve(import.meta.dirname, '..') -/** - * Production package sources allowed to read `process.env`, each with the - * reason it is a process fact rather than a user-configurable value. Adding a - * row is a deliberate act: state which of the three owners it belongs to and - * why it cannot go there. - */ -const ENV_READ_ALLOWLIST: Readonly> = { - // The environment plane itself. - 'packages/util/environment/src/index.ts': 'defines the snapshot; the inherited environment is its input', - 'packages/ui/app-boot/src/index.ts': 'the app bootstrap that builds the snapshot and reads $DSH_SNAPSHOT', - 'packages/util/paths/src/index.ts': 'resolves $DSH_HOME before any snapshot exists', - - // Process-launch facts owned by the boundary that spawns or is spawned. - 'packages/subprocess/subprocess/src/index.ts': 'scrubs the parent environment for children', - 'packages/workflow/workflow-workerthread/src/host.ts': 'passes the parent environment to a worker thread', - 'packages/ui/tui/src/index.ts': 'reads $COLORTERM, a terminal capability of this process', - 'packages/lsp/lsp-local/src/index.ts': 'passes the parent environment to a language server it spawns', - 'packages/cordis/repository-plugin/src/index.ts': 'resolves an MCP manifest against the spawning environment', - 'packages/host/directory-picker-native/src/win32-dialog-host.ts': 'builds the child environment for the dialog worker it spawns', - 'packages/host/directory-picker-native/src/win32-dialog-worker.ts': 'the spawned worker reads the title its parent passed on the env channel', - 'packages/bash/pwsh-local/src/resolve.ts': 'locates pwsh through $ProgramFiles and $SystemRoot, Windows install layout rather than user configuration', - - // Bootstrap-only DSH_* switches, which no discovered file may set. - 'packages/skill/skill-local/src/index.ts': 'reads $DSH_AGENTS_HOME and $DSH_BUNDLED_SKILL_DIR, both bootstrap-only', - 'packages/web/web/src/index.ts': 'reads $DSH_WEB_SEARCH_PROVIDER and $DSH_WEB_FETCH_PROVIDER, both bootstrap-only', - 'packages/host/apiproxy/src/native-path-opener.ts': 'reads the WSL interop markers of this process to pick an opener', - 'packages/host/directory-picker-auto/src/index.ts': 'reads launch facts (display, SSH) of this process', - 'packages/host/directory-picker-auto/src/resolve.ts': 'reads launch facts (display, SSH) of this process', - - // Telemetry identity and consent, resolved once per process at bootstrap. - 'packages/telemetry/session-telemetry-otel/src/user-id.ts': 'derives a machine identity from process facts', - 'packages/sdk/telemetry/src/consent-resolver.ts': 'reads the SDK bootstrap consent switch', - 'packages/sdk/telemetry/src/anonymous-id.ts': 'derives a machine identity from process facts', - - // SDK and example bins: their own app bootstrap, outside the product CLI. - 'packages/sdk/sdk-client/src/client.ts': 'SDK host bootstrap', - 'packages/sdk/helper/src/features/builtin/provider.ts': 'SDK scaffolding reads the developer environment', - 'packages/sdk/helper/src/features/builtin/app.ts': 'SDK scaffolding reads the developer environment', - 'packages/sdk/helper/src/package-managers/package-manager.ts': 'detects the invoking package manager', - 'packages/sdk/create-sdk/src/create-wizard.ts': 'SDK scaffolding reads the developer environment', - 'packages/examples/jsonrpc-demo/src/bin.ts': 'demo bin bootstrap', - 'packages/examples/acp-demo/src/bin.ts': 'demo bin bootstrap', - - // Test and replay infrastructure. - 'packages/support/loader-smoke/src/index.ts': 'test launcher composing a child environment', - 'packages/support/llm-replay/src/index.ts': 'replay fixture switch', - 'packages/support/acp-snapshot/src/launcher.ts': 'snapshot launcher composing a child environment', - - // Browser bundle: `process.env` is replaced at build time, never read at runtime. - 'packages/client/runtime/src/client/contract/store.ts': 'build-time constant folded by the bundler', -} - /** Shipped Cordis configuration these rules apply to. */ const SHIPPED_CONFIG_GLOBS = [ 'apps/*/config/*.yml', @@ -95,17 +31,6 @@ const INLINE_DENY = /^\s*(apiKey|baseURL|apiKeyEnv|authToken|headers)\s*:\s*!!js const failures: string[] = [] -for (const file of globSync('packages/*/*/src/**/*.ts', { cwd: ROOT })) { - const rel = file.split(sep).join('/') - if (!readFileSync(resolve(ROOT, rel), 'utf8').includes('process.env')) continue - if (rel in ENV_READ_ALLOWLIST) continue - failures.push( - `${rel}: reads process.env directly. A credential belongs to ctx.credentials, a user-configurable` - + ' value to environmentOf(ctx) plus its owner\'s resolve step, and a process-launch fact to the app' - + ' bootstrap. If it is genuinely one of those, add it to ENV_READ_ALLOWLIST with the reason.', - ) -} - for (const glob of SHIPPED_CONFIG_GLOBS) { for (const file of globSync(glob, { cwd: ROOT })) { const rel = file.split(sep).join('/') @@ -126,8 +51,7 @@ if (failures.length > 0) { process.exit(1) } -const allowed = Object.keys(ENV_READ_ALLOWLIST).length process.stdout.write( - `verify-config-source-ownership: no unregistered process.env reads (${String(allowed)} allowlisted)` - + ' and no credential or endpoint inlined in shipped configuration.\n', + 'verify-config-source-ownership: no credential or endpoint uses the ordinary inline environment form' + + ' in shipped configuration.\n', ) From 3dfb16008de63d472b10b3c6db074e89c2322c17 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 7 Aug 2026 21:24:16 +0800 Subject: [PATCH 17/27] docs(config): align environment and credential contracts Code already treats $DSH_HOME/.env as ordinary launch environment and stores managed credentials in .credentials.yaml, but public docs still described the old store, old precedence, removed literal adapter keys, and the deleted TUI. That directed users to the wrong file and overstated the supported configuration surface. Update the existing English and Chinese owners in place, document inherited > managed > project > user credential resolution, and record the loadLayeredEnv export. Regenerate only pairing records and the source-line catalog; add no new section or site route. --- ...026-08-04-configuration-source-ownership.i18n.yaml | 4 ++-- .../2026-08-04-configuration-source-ownership.md | 4 ++-- .../2026-08-04-configuration-source-ownership.zh.md | 4 ++-- ...dentials-yaml-and-user-environment-layer.i18n.yaml | 4 ++-- ...-04-credentials-yaml-and-user-environment-layer.md | 11 +++++------ ...-credentials-yaml-and-user-environment-layer.zh.md | 11 +++++------ apps/cli/reference/README.i18n.yaml | 4 ++-- apps/cli/reference/README.md | 2 +- apps/cli/reference/README.zh.md | 2 +- docs/config-catalog.md | 2 +- docs/cordis-tutorial/05-config.i18n.yaml | 4 ++-- docs/cordis-tutorial/05-config.md | 6 +++--- docs/cordis-tutorial/05-config.zh.md | 6 +++--- docs/user/guide/config.i18n.yaml | 4 ++-- docs/user/guide/config.md | 9 ++------- docs/user/guide/config.zh.md | 9 ++------- docs/user/guide/index.i18n.yaml | 4 ++-- docs/user/guide/index.md | 2 -- docs/user/guide/index.zh.md | 2 -- docs/user/guide/providers.i18n.yaml | 4 ++-- docs/user/guide/providers.md | 6 +++--- docs/user/guide/providers.zh.md | 6 +++--- packages/bundle/base/cordis.patch.yml | 10 ++++------ packages/client/ui-models/README.i18n.yaml | 4 ++-- packages/client/ui-models/README.md | 6 +++--- packages/client/ui-models/README.zh.md | 6 +++--- .../credentials/credentials-local/README.i18n.yaml | 4 ++-- packages/credentials/credentials-local/README.md | 2 +- packages/credentials/credentials-local/README.zh.md | 2 +- packages/credentials/credentials-local/src/index.ts | 7 +++---- packages/credentials/credentials/README.i18n.yaml | 4 ++-- packages/credentials/credentials/README.md | 2 +- packages/credentials/credentials/README.zh.md | 2 +- packages/llm/llm-pi-ai/README.i18n.yaml | 4 ++-- packages/llm/llm-pi-ai/README.md | 10 +++++----- packages/llm/llm-pi-ai/README.zh.md | 10 +++++----- packages/llm/llm-retry/README.i18n.yaml | 4 ++-- packages/llm/llm-retry/README.md | 2 +- packages/llm/llm-retry/README.zh.md | 2 +- packages/ui/app-boot/README.i18n.yaml | 4 ++-- packages/ui/app-boot/README.md | 5 +++-- packages/ui/app-boot/README.zh.md | 5 +++-- 42 files changed, 94 insertions(+), 111 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml index 38d2409b9d..32f4e05648 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.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 .agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md -2026-08-04-configuration-source-ownership.md: e06dbc85f2307fa8a50fba13000f42306d69d9bf -2026-08-04-configuration-source-ownership.zh.md: 6c6a128f1279a271f583e0bf4bcd27d0e5b81162 +2026-08-04-configuration-source-ownership.md: 2603736e35fbf838609fd2ca133785cfe5534e27 +2026-08-04-configuration-source-ownership.zh.md: 98c9291503201db81b5b4797dcc04823e0a27db7 diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md index e06dbc85f2..2603736e35 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md @@ -40,7 +40,7 @@ inherited process environment (read-only, wins) The launching environment wins because `DEEPSEEK_API_KEY=… dsh`, a CI secret, and a container `-e` are the one override an operator must be able to apply per run without editing machine state, and because it cannot be edited from inside it must be *visibly* read-only. Configuration is meant to carry only the *reference* — which name to resolve — and that name follows the non-secret ordering above. -**The project the harness is launched in is trusted, by default and without a prompt.** A checkout may carry its own endpoint, its own ordinary variables, and its own key; the key ranks below the managed store, so a key stored through the web page or TUI is never displaced by one a checkout happens to contain. `EnvironmentSnapshot.getFrom(name, sources)` still searches only the layers a caller names, and omitting one is a refusal rather than a demotion — the mechanism exists for the decisions where a layer must be unreachable, not because the project is one of them today. +**The project the harness is launched in is trusted, by default and without a prompt.** A checkout may carry its own endpoint, its own ordinary variables, and its own key; the key ranks below the managed store, so a key stored through the Models page is never displaced by one a checkout happens to contain. `EnvironmentSnapshot.getFrom(name, sources)` still searches only the layers a caller names, and omitting one is a refusal rather than a demotion — the mechanism exists for the decisions where a layer must be unreachable, not because the project is one of them today. **Trust does not extend to changing the harness itself.** `isBootstrapOnly` rejects, at load and before anything is materialized, any `.env` that sets a variable governing how a process launches (`PATH`, `SHELL`, `NODE_OPTIONS`, `LD_PRELOAD`), what code a runtime executes before the program it was asked to run (`BASH_ENV`, `PERL5OPT`, `PYTHONSTARTUP`, `RUBYOPT`, `JAVA_TOOL_OPTIONS`, the Git hook commands), where model-visible instructions load from (the whole `DSH_*` namespace, `HOME`, `XDG_*`), or how the network is reached and trusted (proxy and CA variables). Matching is case-insensitive, so `https_proxy` is not a bypass. @@ -55,7 +55,7 @@ The line is that these take effect with no user action, before any turn, outside - The web credential form now takes effect against an older key in the user's `.env`; only a key exported in the launching shell still makes it read-only, and the diagnostic says so. - A `.env` holding `DSH_*`, `PATH`, or a proxy variable fails the launch instead of being applied. Developers keeping switches in a repository `.env` move them to their shell — a deliberate, loud break. - Composition is no longer overridable by a stale shell endpoint. It is still overridable by a user's stored `settings.yaml`, which is the settings seam's layering and not something this note changes; the product CLI offers no flag above it, so a deployment that must win against stored settings owns its own bin or loader tree. -- Not solved: the layers are still materialized into `process.env`, so ordinary project variables continue to reach child processes under the subprocess scrub. Bootstrap variables cannot come from a file at all, which closes the escalation path; a project `.env` setting something like `GIT_SSH_COMMAND` for the tools an agent runs remains possible and is recorded as a limitation on the package. +- Not solved: the layers are still materialized into `process.env`, so ordinary project variables continue to reach child processes under the subprocess scrub. Bootstrap variables cannot come from a file at all; the environment package records the remaining subprocess reach as a limitation. - The LLM adapters no longer accept a literal `apiKey`: configuration carries the reference and nothing else, so a settings document cannot become a second credential store. No adapter namespace is strict, so writing one is dropped rather than rejected. The web-search providers still declare a `role('secret')` literal key; they register no settings namespace, so nothing can shadow a stored credential through them, but the claim is about the adapters rather than the repository as a whole. - Exa and Perplexity still capture their key at load time rather than through the credential seam. They no longer read raw `process.env` — they resolve through the trusted layers — but converting them to per-request seam resolution is separate work. diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md index 6c6a128f12..98c9291503 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md @@ -42,7 +42,7 @@ inherited process environment (read-only, wins) 继承环境优先,因为 `DEEPSEEK_API_KEY=… dsh`、CI 机密与容器 `-e` 是运维必须能按次施加、且无需改动机器状态的那一种覆盖;而它无法从进程内部修改,就必须*可见地*只读。配置本应只携带*引用*——解析哪个名字——该名字本身遵循上面的非密钥顺序。 -**harness 被启动于其中的项目默认可信,且不做询问。** 一个 checkout 可以携带自己的 endpoint、自己的普通变量和自己的密钥;密钥排在受管存储之下,因此通过 Web 页面或 TUI 存下的密钥绝不会被 checkout 中恰好带有的那一个顶掉。`EnvironmentSnapshot.getFrom(name, sources)` 仍然只搜索调用方点名的层,省略某层仍是拒绝而不是降级——该机制是为「某一层必须不可达」的那些决策准备的,而项目层今天不在其列。 +**harness 被启动于其中的项目默认可信,且不做询问。** 一个 checkout 可以携带自己的 endpoint、自己的普通变量和自己的密钥;密钥排在受管存储之下,因此通过 Models 页存下的密钥绝不会被 checkout 中恰好带有的那一个顶掉。`EnvironmentSnapshot.getFrom(name, sources)` 仍然只搜索调用方点名的层,省略某层仍是拒绝而不是降级——该机制是为「某一层必须不可达」的那些决策准备的,而项目层今天不在其列。 **信任不延伸到改变 harness 本身。** `isBootstrapOnly` 会在加载时、且在物化任何内容之前,拒绝任何设置了下列变量的 `.env`:决定进程如何启动的(`PATH`、`SHELL`、`NODE_OPTIONS`、`LD_PRELOAD`)、决定运行时在执行被要求运行的程序之前先执行哪些代码的(`BASH_ENV`、`PERL5OPT`、`PYTHONSTARTUP`、`RUBYOPT`、`JAVA_TOOL_OPTIONS`、Git 的钩子命令)、决定模型可见指令从哪里加载的(整个 `DSH_*` 命名空间、`HOME`、`XDG_*`),以及决定网络如何抵达与信任的(proxy 与 CA 变量)。匹配不区分大小写,因此 `https_proxy` 不是绕过手段。 @@ -57,7 +57,7 @@ inherited process environment (read-only, wins) - Web 凭据表单现在能压过用户 `.env` 里更旧的密钥;只有在启动 shell 里 export 的密钥才会让它变成只读,诊断信息也会这么说。 - 含 `DSH_*`、`PATH` 或 proxy 变量的 `.env` 会导致启动失败而不是被应用。把开关放在仓库 `.env` 里的开发者需要改放到 shell——这是一次刻意且响亮的破坏。 - composition 不再会被陈旧的 shell endpoint 覆盖。但它仍然会被用户已存的 `settings.yaml` 覆盖,这是 settings seam 的分层方式,本 Note 不改变它;产品 CLI 没有高于它的标志,因此需要压过已存 settings 的部署方要自带 bin 或 loader 配置树。 -- 未解决的:各层仍然会被物化进 `process.env`,因此普通项目变量继续按子进程清洗规则抵达子进程。bootstrap 变量完全不能来自文件,提权路径已封闭;项目 `.env` 为 agent 运行的工具设置诸如 `GIT_SSH_COMMAND` 之类的变量仍然可能,已作为限制记录在该包上。 +- 未解决的:各层仍然会被物化进 `process.env`,因此普通项目变量继续按子进程清洗规则抵达子进程。bootstrap 变量完全不能来自文件;其余变量抵达子进程的限制记录在环境包中。 - LLM 适配器不再接受字面 `apiKey`:配置只携带引用,因此 settings 文档无法成为第二个凭据存储。由于没有任何适配器 namespace 是 strict 的,写入该键会被 schema 丢弃而不是报错。web-search 提供方仍声明 `role('secret')` 的字面密钥字段;它们不注册 settings namespace,因此无法借此遮蔽已存凭据,但这条声明的范围是适配器,而不是整个仓库。 - Exa 与 Perplexity 仍在加载时捕获密钥,而不是经凭据 seam。它们不再读裸 `process.env`——改为经受信层解析——但把它们改造成按请求经 seam 解析是另一件事。 diff --git a/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.i18n.yaml index 376838d151..ba4444166c 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.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 .agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.md -2026-08-04-credentials-yaml-and-user-environment-layer.md: f03f3f885c13476619ba3cda51e2dfed7e3258c1 -2026-08-04-credentials-yaml-and-user-environment-layer.zh.md: 7cce1daeffadb18678f00a5c9acd1b14c6ac1b22 +2026-08-04-credentials-yaml-and-user-environment-layer.md: 4ecbc41adf4e22c74ecf425c2caf628efdf7cf54 +2026-08-04-credentials-yaml-and-user-environment-layer.zh.md: 370179b442783f4f8ecd8e3badbd236a924f5f81 diff --git a/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.md b/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.md index f03f3f885c..4ecbc41adf 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.md +++ b/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.md @@ -6,7 +6,7 @@ English | [中文](2026-08-04-credentials-yaml-and-user-environment-layer.zh.md) ## Problem -`$DSH_HOME/.env` carried two incompatible jobs. It was the writable secret store of [`credentials-local`](../../../../packages/credentials/credentials-local/README.md), so no surface could hoist it into `process.env` — hoisting would make every stored key read as a read-only launch override and block rotation from the TUI and the web page. But its name and dotenv format promise an environment file, so users put non-secrets in it and those values reached nothing: a `DEEPSEEK_BASE_URL` beside a working `DEEPSEEK_API_KEY` in the same file was silently ignored, because only the credential provider read the document and it addresses credential references alone. +`$DSH_HOME/.env` carried two incompatible jobs. It was the writable secret store of [`credentials-local`](../../../../packages/credentials/credentials-local/README.md), so no surface could hoist it into `process.env` — hoisting would make every stored key read as a read-only launch override and block rotation from the Models page. But its name and dotenv format promise an environment file, so users put non-secrets in it and those values reached nothing: a `DEEPSEEK_BASE_URL` beside a working `DEEPSEEK_API_KEY` in the same file was silently ignored, because only the credential provider read the document and it addresses credential references alone. One file cannot be both a store the Harness owns and isolates and a layer that propagates by ordinary environment rules. The [request-level credential decision](2026-07-29-request-level-llm-config-credentials.md) chose dotenv to match peer products' home `.env`, and the conflation was not visible until a non-secret needed the same file. @@ -23,16 +23,15 @@ OPENAI_API_KEY: sk-… Because the document holds credentials and nothing else, every deviation is a rejection rather than a skipped entry: a non-mapping root, a key that is not a POSIX identifier, a non-string value, an empty string, a duplicate key, and malformed YAML all fail — loud at boot and at a write, warn-and-keep-the-last-good-snapshot on a live reload. A silently ignored key would read as "the secret I stored has no effect", which is the failure this change exists to remove. The dotenv physical-line editor is replaced by a patch of the parsed document, so comments and untouched entries keep their formatting, any string value round-trips (multi-line included), and no entry is unwritable for want of a quoting style. The writer lock, read-modify-write, atomic `0600` write under a `0700` directory, exact-path watcher, content-equality self-write suppression, and quiescent disposal are unchanged. -**`$DSH_HOME/.env` is the user's ordinary environment layer.** `loadLayeredEnv` in [`dsh-app-boot`](../../../../packages/ui/app-boot/README.md) loads the invoking directory's `.env` and then the Harness home's, giving `user < project < inherited` — `process.loadEnvFile` never replaces a name already set, which is what the load order exploits and what the app-boot tests pin across all three layers. The Harness home is resolved from the inherited environment *before* either file loads, so a project `.env` cannot redirect which user document is read. Only the product CLI layers these files; SDK and example bins keep loading their own directory through `loadEnv` and must not inherit a developer's `$DSH_HOME`. +**`$DSH_HOME/.env` is the user's ordinary environment layer.** `loadLayeredEnv` in [`dsh-app-boot`](../../../../packages/ui/app-boot/README.md) parses the invoking directory's `.env` and then the Harness home's, giving `user < project < inherited` by materializing each accepted value only when the process has no higher-layer value. The Harness home is resolved from the inherited environment *before* either file loads, so a project `.env` cannot redirect which user document is read. Only the product CLI layers these files; SDK and example bins keep loading their own directory through `loadEnv` and must not inherit a developer's `$DSH_HOME`. -Credential precedence is unchanged this round: the live process environment still wins read-only over the file, and `set`/`unset` still reject a write the environment would shadow. Whether a provider-managed store should instead win over the environment is a separate decision, deliberately not taken here. +Credential precedence distinguishes the inherited environment from discovered files: the inherited value stays the read-only per-run override, the managed document wins next, and project then user `.env` values remain writable fallbacks. A `set` therefore replaces a discovered-file value instead of rejecting a write that only the flattened `process.env` view would consider shadowed. -There is no migration. The product is unreleased, and a key already in `$DSH_HOME/.env` keeps resolving through the new environment layer — as a read-only `env` source that shadows the stored one, which is exactly what the diagnostics say. +There is no migration. A key already in `$DSH_HOME/.env` keeps resolving as a fallback, while the managed document wins as soon as the Models page stores that reference. ## Consequences -- Given up: a key left in `$DSH_HOME/.env` is now hoisted into `process.env`, so it reaches subprocesses under the [subprocess credential scrub](../../../../packages/subprocess/subprocess/README.md) rather than staying inside the provider. That is the honest meaning of "ordinary environment layer"; a secret the Harness should own and isolate belongs in `.credentials.yaml`, which is never hoisted. -- Given up: the same key shadows `.credentials.yaml` and makes the web Models page's write reject. The seam already reports `source: 'env', writable: false` for that state, and the rejection message now names the loaded `.env` as a place to unset it. +- Given up: a key left in `$DSH_HOME/.env` is materialized into `process.env`, so it reaches subprocesses under the [subprocess credential scrub](../../../../packages/subprocess/subprocess/README.md) rather than staying inside the provider. It remains a writable fallback below `.credentials.yaml`; a secret the Harness should own and isolate belongs in the managed document, which is never materialized. - Bought: a non-secret in the user's `.env` finally takes effect, which was the original defect; the document format can reject what it cannot serve; and `0600` covers a file that holds only secrets instead of a file users are told to put ordinary configuration in. - The `0600` the provider writes is also enforced on what it reads: on POSIX, a document with any group or other permission bit fails the launch before its contents are read, at boot and on every reload, and the diagnostic names the `chmod 600` repair. Windows has no mode to inspect — its ACLs are not expressible here — so the check is skipped rather than faked. - The `0600` boundary still stops other OS users and not the model, unchanged by this split — the [provider README](../../../../packages/credentials/credentials-local/README.md) owns that limit and the keychain-provider deferral. diff --git a/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.zh.md b/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.zh.md index 7cce1daeff..370179b442 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.zh.md @@ -6,7 +6,7 @@ Status: implemented ## Problem -`$DSH_HOME/.env` 同时承担了两件互不相容的工作。它是 [`credentials-local`](../../../../packages/credentials/credentials-local/README.md) 的可写密钥存储,因此任何表层都不能把它提升进 `process.env`——一旦提升,每个已存密钥都会读作只读的启动时覆盖,从而阻断从 TUI 与 Web 页面轮换密钥。但它的文件名和 dotenv 格式承诺的是一个环境文件,于是用户把非密钥值放进去,而那些值哪儿也到不了:同一个文件里,一个能用的 `DEEPSEEK_API_KEY` 旁边的 `DEEPSEEK_BASE_URL` 会被静默忽略,因为只有凭据 provider 读这份文档,而它只寻址凭据引用。 +`$DSH_HOME/.env` 同时承担了两件互不相容的工作。它是 [`credentials-local`](../../../../packages/credentials/credentials-local/README.md) 的可写密钥存储,因此任何表层都不能把它提升进 `process.env`——一旦提升,每个已存密钥都会读作只读的启动时覆盖,从而阻断从 Models 页轮换密钥。但它的文件名和 dotenv 格式承诺的是一个环境文件,于是用户把非密钥值放进去,而那些值哪儿也到不了:同一个文件里,一个能用的 `DEEPSEEK_API_KEY` 旁边的 `DEEPSEEK_BASE_URL` 会被静默忽略,因为只有凭据 provider 读这份文档,而它只寻址凭据引用。 一个文件无法既是由 Harness 拥有并隔离的存储,又是按普通环境规则传播的层。[请求级凭据决策](2026-07-29-request-level-llm-config-credentials.md)当初选择 dotenv 是为了对齐同类产品的 home `.env`,而这种混同直到有非密钥值需要用同一个文件时才暴露出来。 @@ -23,16 +23,15 @@ OPENAI_API_KEY: sk-… 因为该文档只存放凭据、别无他物,任何偏离都是拒绝而不是跳过条目:非 mapping 的根、非 POSIX 标识符的键、非字符串值、空字符串、重复键以及格式错误的 YAML 全部失败——启动时和写入时响亮失败,运行期热重载则告警并保留最后可用快照。被静默忽略的键读起来就是「我存进去的密钥没有生效」,而这正是本次变更要消除的失败。dotenv 物理行编辑器被替换为对已解析文档打补丁,因此注释与未触及条目的排版都会保留,任何字符串值都能往返(含多行),也不会再有条目因为缺少可用引号样式而不可写。写锁、read-modify-write、`0700` 目录下的 `0600` 原子写、精确路径 watcher、按内容相等抑制自写、以及 dispose 时的完全停稳,均保持不变。 -**`$DSH_HOME/.env` 是用户的普通环境层。** [`dsh-app-boot`](../../../../packages/ui/app-boot/README.md) 中的 `loadLayeredEnv` 先加载调用目录的 `.env`,再加载 Harness home 的,得到 `用户 < 项目 < 继承`——`process.loadEnvFile` 从不替换已经设置的名字,加载顺序正是利用了这一点,app-boot 的测试也把三层一起钉住。Harness home 在两个文件加载*之前*就从继承的环境解析完毕,因此项目 `.env` 无法改变读取哪份用户文档。只有产品 CLI(命令行界面)叠加这两个文件;SDK 与示例 bin 仍通过 `loadEnv` 加载各自的目录,绝不继承开发者的 `$DSH_HOME`。 +**`$DSH_HOME/.env` 是用户的普通环境层。** [`dsh-app-boot`](../../../../packages/ui/app-boot/README.md) 中的 `loadLayeredEnv` 先解析调用目录的 `.env`,再解析 Harness home 的,并且只在进程中没有更高层值时物化每个已接受的值,从而得到 `用户 < 项目 < 继承`。Harness home 在两个文件加载*之前*就从继承的环境解析完毕,因此项目 `.env` 无法改变读取哪份用户文档。只有产品 CLI(命令行界面)叠加这两个文件;SDK 与示例 bin 仍通过 `loadEnv` 加载各自的目录,绝不继承开发者的 `$DSH_HOME`。 -本轮不改凭据优先级:活跃进程环境仍然只读地优先于文件,`set`/`unset` 仍然拒绝会被环境遮蔽的写入。provider 管理的存储是否应当反过来压过环境,是另一个决策,此处刻意不作。 +凭据优先级会区分继承环境与发现的文件:继承值仍是只读的按次覆盖,其后是受管文档,再后是仍可写的项目与用户 `.env` 后备值。因此 `set` 会替换发现文件中的值,而不是因为扁平化的 `process.env` 视图认为写入会被遮蔽就加以拒绝。 -不做迁移。产品尚未发布,而已经放在 `$DSH_HOME/.env` 里的密钥会继续通过新的环境层解析——作为只读的 `env` 来源遮蔽已存储的那一份,诊断给出的也正是这个结论。 +不做迁移。已经放在 `$DSH_HOME/.env` 里的密钥会继续作为后备值解析;Models 页一旦存储该引用,受管文档就会优先。 ## Consequences -- 放弃的:留在 `$DSH_HOME/.env` 里的密钥现在会被提升进 `process.env`,因而会按[子进程凭据清洗](../../../../packages/subprocess/subprocess/README.md)的规则抵达子进程,而不再留在 provider 内部。这就是「普通环境层」的诚实含义;需要由 Harness 拥有并隔离的密钥属于 `.credentials.yaml`,后者永不提升。 -- 放弃的:同一个键会遮蔽 `.credentials.yaml`,并让 Web Models 页的写入被拒。seam 对这种状态本来就报告 `source: 'env', writable: false`,而拒绝信息现在会把已加载的 `.env` 一并指为需要清除的位置。 +- 放弃的:留在 `$DSH_HOME/.env` 里的密钥会被物化进 `process.env`,因而会按[子进程凭据清洗](../../../../packages/subprocess/subprocess/README.md)的规则抵达子进程,而不再留在 provider 内部。它仍是 `.credentials.yaml` 之下的可写后备值;需要由 Harness 拥有并隔离的密钥属于受管文档,后者永不物化。 - 换来的:用户 `.env` 里的非密钥值终于生效,这正是最初的缺陷;文档格式可以拒绝它无法承担的内容;`0600` 保护的是一个只存密钥的文件,而不是一个我们同时叫用户往里写普通配置的文件。 - provider 写入时用的 `0600` 同样约束它读取的内容:在 POSIX 上,只要文档带有任何 group 或 other 权限位,就会在读取内容之前让启动失败——启动时与每次 reload 都检查,诊断里给出 `chmod 600` 的修复命令。Windows 没有可检查的 mode(其 ACL 无法在此表达),因此跳过该检查而不是伪造它。 - `0600` 这条边界仍然只挡其他 OS 用户、挡不住模型,本次拆分未改变这一点——该限制及 keychain provider 的延后项归 [provider README](../../../../packages/credentials/credentials-local/README.md) 所有。 diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index 07d7810529..e64141c31d 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/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/reference/README.md -README.md: 8b8a0e7dbebafedd6a4f8d988adb3fd11c7bd026 -README.zh.md: d1d6d5a594596a8be5db30021163f0fcea4a95bf +README.md: c7c7b2aa231d4c9f4b3fbf31663237c8457eb051 +README.zh.md: 5439aa78b74415c8e6264d21f5c52e5cee5b38ee diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index 8b8a0e7dbe..c7c7b2aa23 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -59,7 +59,7 @@ New sessions default to the `workspace-write` permission preset. Bash and filesy ## Shared deployment behavior -The base bundle mounts the native DeepSeek adapter, settings and credential providers, stable `web_search`, repository Plugin support, and session telemetry. Provider credentials live in `$DSH_HOME/.env` or the ambient environment and remain rotatable because the launcher never hoists the credential file into `process.env`. Search uses `DEEPSEEK_API_KEY` and accepts `DEEPSEEK_SEARCH_BASE_URL`; `web_fetch` is disabled unless a patch layer inserts a provider and enables it. +The base bundle mounts the native DeepSeek adapter, settings and credential providers, stable `web_search`, repository Plugin support, and session telemetry. Provider credentials resolve from the inherited environment, `$DSH_HOME/.credentials.yaml`, the invoking directory's `.env`, then `$DSH_HOME/.env`; the managed document is never materialized into `process.env`, while both `.env` files are ordinary launch environment layers. Search uses `DEEPSEEK_API_KEY` and accepts `DEEPSEEK_SEARCH_BASE_URL`; `web_fetch` is disabled unless a patch layer inserts a provider and enables it. Session events stream as OTLP/HTTP logs by default. `DSH_TELEMETRY_OTLP_URL` selects another collector. Any non-empty `DSH_TELEMETRY_DISABLED` disables the telemetry row before boot. The shipped base has no telemetry redaction rule, so exported records can contain message text, tool arguments and results, and workspace paths; the [telemetry Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md) owns that deployment decision. diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index d1d6d5a594..5439aa78b7 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -59,7 +59,7 @@ dsh web --dump-config ## 共享部署行为 -基础组合包挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search`、repository Plugin 支持和会话遥测。提供方凭据存放在 `$DSH_HOME/.env` 或环境中;启动器从不把凭据文件提升到 `process.env`,因此凭据可以轮换。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`;只有 patch 层插入提供方并启用 `web_fetch` 后,该工具才可用。 +基础组合包挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search`、repository Plugin 支持和会话遥测。提供方凭据依次从继承环境、`$DSH_HOME/.credentials.yaml`、调用目录的 `.env` 和 `$DSH_HOME/.env` 解析;受管文档从不物化进 `process.env`,而两个 `.env` 文件都是普通启动环境层。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`;只有 patch 层插入提供方并启用 `web_fetch` 后,该工具才可用。 会话事件默认作为 OTLP/HTTP 日志流式发送。`DSH_TELEMETRY_OTLP_URL` 选择其他 collector。任何非空 `DSH_TELEMETRY_DISABLED` 都会在启动前禁用遥测配置行。随附基础配置没有遥测脱敏规则,因此导出的记录可能包含消息文本、工具参数与结果以及 workspace 路径;该部署决策由[遥测 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md)负责。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 2902d30c8f..d207207073 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -437,7 +437,7 @@ export interface Config { } ``` -Source: [`packages/credentials/credentials-local/src/index.ts:55`](../packages/credentials/credentials-local/src/index.ts) +Source: [`packages/credentials/credentials-local/src/index.ts:54`](../packages/credentials/credentials-local/src/index.ts) ## `@deepseek-ai/dsh-frontend-static` diff --git a/docs/cordis-tutorial/05-config.i18n.yaml b/docs/cordis-tutorial/05-config.i18n.yaml index 01047ad516..db300b1745 100644 --- a/docs/cordis-tutorial/05-config.i18n.yaml +++ b/docs/cordis-tutorial/05-config.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/cordis-tutorial/05-config.md -05-config.md: fc19add239636fa9e7071d9c77e48595caec1f08 -05-config.zh.md: 0c8170f518f0c87ab5c754606436496a4ff9d51e +05-config.md: 8d4043e33a58fc425d82d9846ff82473bcdef4c1 +05-config.zh.md: e9463bd34e9c72dbae7b1ceb9907e35edf7b773b diff --git a/docs/cordis-tutorial/05-config.md b/docs/cordis-tutorial/05-config.md index fc19add239..8d4043e33a 100644 --- a/docs/cordis-tutorial/05-config.md +++ b/docs/cordis-tutorial/05-config.md @@ -69,12 +69,12 @@ The plugin's fiber goes to FAILED, and this tutorial's launcher exits with statu ## Computed config values -The loader used in this repo supports a `!!js` tag for config values that must be computed at load time, such as reading an API key from the environment: +The loader used in this repo supports a `!!js` tag for config values that must be computed at load time: ```yaml -- name: '@deepseek-ai/dsh-llm-deepseek' +- name: './config-demo.ts' config: - apiKey: !!js process.env.DEEPSEEK_API_KEY + greeting: !!js process.env.DEMO_GREETING ?? 'Hello' ``` `!!js` works **only inside `config`**. Entry metadata (`name`, `id`, `disabled`, `inject`, ...) is static; `disabled: !!js ...` produces a truthy expression object that always disables the entry. See [loader configuration](../cordis-primer.md#loader-configuration). diff --git a/docs/cordis-tutorial/05-config.zh.md b/docs/cordis-tutorial/05-config.zh.md index 0c8170f518..e9463bd34e 100644 --- a/docs/cordis-tutorial/05-config.zh.md +++ b/docs/cordis-tutorial/05-config.zh.md @@ -69,12 +69,12 @@ ValidationError: invalid config: ## 计算得到的配置值 -本仓库使用的 loader 支持 `!!js` 标签,用于必须在加载时计算的配置值,例如从环境中读取 API key: +本仓库使用的 loader 支持 `!!js` 标签,用于必须在加载时计算的配置值: ```yaml -- name: '@deepseek-ai/dsh-llm-deepseek' +- name: './config-demo.ts' config: - apiKey: !!js process.env.DEEPSEEK_API_KEY + greeting: !!js process.env.DEMO_GREETING ?? 'Hello' ``` `!!js` **仅在 `config` 内有效**。Cordis 配置项的元数据(`name`、`id`、`disabled`、`inject` 等)是静态的;`disabled: !!js ...` 会生成一个真值表达式对象,始终禁用该 Cordis 配置项。详见 [loader 配置](../cordis-primer.md#loader-configuration)。 diff --git a/docs/user/guide/config.i18n.yaml b/docs/user/guide/config.i18n.yaml index 172af1bcac..954eab633d 100644 --- a/docs/user/guide/config.i18n.yaml +++ b/docs/user/guide/config.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/user/guide/config.md -config.md: 0f1a99ed0afdab13d052ae2714fc2e1718d3d85e -config.zh.md: 74e14e4e6e1fbb38a7a8d5167a4747080a662128 +config.md: 34ab38c60cc7a9b6026f5be2be47f440eb6cb08d +config.zh.md: ce965dfc67c759ffcbad7b044b74abb2025851c2 diff --git a/docs/user/guide/config.md b/docs/user/guide/config.md index 0f1a99ed0a..34ab38c60c 100644 --- a/docs/user/guide/config.md +++ b/docs/user/guide/config.md @@ -18,10 +18,6 @@ A minimal configuration is a list of plugin entries: ```yaml - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' - config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - models: - - deepseek-v4-flash - id: bash name: '@deepseek-ai/dsh-bash-local' @@ -53,15 +49,14 @@ Cordis starts sibling entries concurrently. A plugin declares required services `dsh --profile ` composes the profile's bundle patch layers (its manifest's `dsh.profile.bundles` list, in order) over an empty root, then the profile's own `~/.dsh/profiles//cordis.patch.yml`, then each `--patch ` overlay, then CLI-flag patches. Later layers win per row. -A patch replaces a row's entire `config` value; it does not deep-merge keys. For example, patching `llm-deepseek` with only `config: { thinking: disabled }` also removes that row's configured `apiKey` and `baseURL`, so restate every key the row must retain. +A patch replaces a row's entire `config` value; it does not deep-merge keys. For example, patching `llm-deepseek` with only `config: { thinking: disabled }` also removes that row's configured `apiKeyEnv` and `baseURL`, so restate every key the row must retain. ## JavaScript values and environment variables -The Cordis loader evaluates runtime expressions tagged with `!!js`. Keep API keys and other secrets in the gitignored `.env` file at the repository root, never in committed configuration. +The Cordis loader evaluates runtime expressions tagged with `!!js` for non-secret runtime values. Bundled LLM adapters carry credential references such as `apiKeyEnv`; the value belongs in an environment layer or `$DSH_HOME/.credentials.yaml`, not Cordis configuration. ```yaml config: - apiKey: !!js process.env.DEEPSEEK_API_KEY cwd: !!js process.cwd() ``` diff --git a/docs/user/guide/config.zh.md b/docs/user/guide/config.zh.md index 74e14e4e6e..ce965dfc67 100644 --- a/docs/user/guide/config.zh.md +++ b/docs/user/guide/config.zh.md @@ -18,10 +18,6 @@ Harness 使用 `cordis.yml` 描述 agent(智能体)加载哪些插件以及 ```yaml - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' - config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - models: - - deepseek-v4-flash - id: bash name: '@deepseek-ai/dsh-bash-local' @@ -53,15 +49,14 @@ Cordis 会并发启动同级配置项。插件通过 `inject` 声明必需服务 `dsh --profile ` 按该 profile 的 manifest(元数据清单)中 `dsh.profile.bundles` 列表的顺序,在空根之上组合各组合包补丁层,随后依次应用该 profile 自己的 `~/.dsh/profiles//cordis.patch.yml`、每个 `--patch ` overlay,最后是 CLI(命令行界面)标志补丁。同一行以较后的层为准。 -补丁会替换目标行的整个 `config` 值,而不是深度合并各个键。例如,只用 `config: { thinking: disabled }` 修补 `llm-deepseek`,也会移除该行原有的 `apiKey` 与 `baseURL`;因此必须重新写出该行需要保留的全部键。 +补丁会替换目标行的整个 `config` 值,而不是深度合并各个键。例如,只用 `config: { thinking: disabled }` 修补 `llm-deepseek`,也会移除该行原有的 `apiKeyEnv` 与 `baseURL`;因此必须重新写出该行需要保留的全部键。 ## JavaScript 值和环境变量 -Cordis loader 使用 `!!js` 标签读取运行时表达式。API key 等凭据应放在仓库根目录、已被 Git 忽略的 `.env` 中,不能提交到配置文件。 +Cordis loader 会求值以 `!!js` 标记的运行时表达式,用于非机密的运行时值。仓库内置的 LLM(大语言模型)适配器携带 `apiKeyEnv` 等凭据引用;对应的值应放在环境层或 `$DSH_HOME/.credentials.yaml`,而不是 Cordis 配置中。 ```yaml config: - apiKey: !!js process.env.DEEPSEEK_API_KEY cwd: !!js process.cwd() ``` diff --git a/docs/user/guide/index.i18n.yaml b/docs/user/guide/index.i18n.yaml index 137a9697c1..b722161724 100644 --- a/docs/user/guide/index.i18n.yaml +++ b/docs/user/guide/index.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/user/guide/index.md -index.md: 4bb9f2e0056792a160877515f142eb36d4f680ac -index.zh.md: 2792547a146b5ca6186bcb69c1c026744e80b326 +index.md: ede09506a996193fe5cf4ae6cd9b64d3529798a6 +index.zh.md: 5f72a6d3099d2d4721eccebae92eacfe72d33bce diff --git a/docs/user/guide/index.md b/docs/user/guide/index.md index 4bb9f2e005..ede09506a9 100644 --- a/docs/user/guide/index.md +++ b/docs/user/guide/index.md @@ -11,8 +11,6 @@ Harness implements every capability an AI agent needs—including LLM calls, too ```yaml # Select the LLM backend - name: '@deepseek-ai/dsh-llm-deepseek' - config: - apiKey: !!js process.env.DEEPSEEK_API_KEY # Select the one-shot application - id: cli-agent diff --git a/docs/user/guide/index.zh.md b/docs/user/guide/index.zh.md index 2792547a14..5f72a6d309 100644 --- a/docs/user/guide/index.zh.md +++ b/docs/user/guide/index.zh.md @@ -11,8 +11,6 @@ Harness 将一个 AI Agent(智能体) 所需要的所有能力——LLM 调 ```yaml # Select the LLM backend - name: '@deepseek-ai/dsh-llm-deepseek' - config: - apiKey: !!js process.env.DEEPSEEK_API_KEY # Select the one-shot application - id: cli-agent diff --git a/docs/user/guide/providers.i18n.yaml b/docs/user/guide/providers.i18n.yaml index 665eae8457..343ac902c1 100644 --- a/docs/user/guide/providers.i18n.yaml +++ b/docs/user/guide/providers.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/user/guide/providers.md -providers.md: 66b6cf25c61a252fbd10a85f8c79c246eeae8abe -providers.zh.md: a2c33c90be971e09ab29e2355ca6a7ae6f947c39 +providers.md: 450b488f292e947a69e4315ea4d1ff74b74d390d +providers.zh.md: 79f776b10eb4bd80663950f93555b353e320b7f1 diff --git a/docs/user/guide/providers.md b/docs/user/guide/providers.md index 66b6cf25c6..450b488f29 100644 --- a/docs/user/guide/providers.md +++ b/docs/user/guide/providers.md @@ -31,7 +31,7 @@ That holds for providers that authenticate with an API key. The catalog also car **Let the endpoint report its models.** Expand **Model catalog** and choose **Fetch available models**: the interrogation asks the endpoint **the form currently shows** — including a base URL edited but not yet saved and a key typed but not yet stored — and offers what it reports as candidates to pick from. A route the installed catalog describes is answered from that catalog with no network call. Adopting a candidate only writes rows into the draft; nothing is stored until you save. -Keys are write-only: the page only ever holds a redacted descriptor, never the literal secret. A key you enter is stored in `$DSH_HOME/.env`, and the profile records only the variable name that references it. +Keys are write-only: the page only ever holds a redacted descriptor, never the literal secret. A key you enter is stored in `$DSH_HOME/.credentials.yaml`, and the profile records only the variable name that references it. ## settings.yaml for advanced configuration @@ -89,9 +89,9 @@ Model ids are not lifecycle configuration. Requesting a model the route does not ## Credentials -Prefer `apiKeyEnv`: it is a *reference* resolved per request, so no secret enters the configuration file. A literal `apiKey` is the escape hatch. Omitting both is what leaves a route unauthenticated, which for a catalog route means pi-ai's own environment discovery. A reference that resolves to nothing fails the request with `MISSING_CREDENTIAL` rather than falling through to whatever unrelated key the environment happens to hold. +Use `apiKeyEnv`: it is a *reference* resolved per request, so no secret enters the configuration file. Omitting it leaves a route unauthenticated, which for a catalog route means pi-ai's own environment discovery. A reference that resolves to nothing fails the request with `MISSING_CREDENTIAL` rather than falling through to whatever unrelated key the environment happens to hold. -References resolve from `$DSH_HOME/.env` — what the Models page's key fields write — and from the matching environment variable when no credential service is mounted. One credential serves every model on its route. +Under `dsh`, references resolve from the inherited environment, the Models page's `$DSH_HOME/.credentials.yaml` store, the invoking directory's `.env`, then `$DSH_HOME/.env`. Without a credential service, a reference reads only the matching environment variable. One credential serves every model on its route. ## Point an agent at the new provider diff --git a/docs/user/guide/providers.zh.md b/docs/user/guide/providers.zh.md index a2c33c90be..79f776b10e 100644 --- a/docs/user/guide/providers.zh.md +++ b/docs/user/guide/providers.zh.md @@ -31,7 +31,7 @@ Harness 出厂就带 DeepSeek,同时挂着一个通用的多提供方适配器 **让端点自己报模型。** 展开**模型目录**后点**获取可用模型**,会按你**当前表单里**的地址与密钥去问端点(地址改了但没保存、密钥刚输入还没存下,都算数),把它报告的模型列成候选让你勾选。内置目录里的路由直接由目录作答,不联网。采纳只是把行写进草稿,最终还是你点保存才落盘。 -密钥是只写的:页面拿到的永远是脱敏描述符,不是明文。写入的密钥存进 `$DSH_HOME/.env`,profile 里只记录引用它的变量名。 +密钥是只写的:页面拿到的永远是脱敏描述符,不是明文。写入的密钥存进 `$DSH_HOME/.credentials.yaml`,profile 里只记录引用它的变量名。 ## settings.yaml:进阶配置 @@ -89,9 +89,9 @@ settings 段落**逐个提供方**地盖在 `cordis.yml` 的同名配置之上 ## 凭据 -优先用 `apiKeyEnv`——它是一个**引用**,每次请求时解析,密钥本身不进配置文件;`apiKey` 字面量是应急出口。两者都不给,才表示这个路由不带认证,对内置目录路由意味着交给 pi-ai 自己的环境发现。给了引用却解析不到,请求会以 `MISSING_CREDENTIAL` 失败,而不是退回去用环境里碰巧存在的某个不相干的 key 计费。 +使用 `apiKeyEnv`——它是一个**引用**,每次请求时解析,密钥本身不进配置文件。省略它会让路由不带认证,对内置目录路由意味着交给 pi-ai 自己的环境发现。给了引用却解析不到,请求会以 `MISSING_CREDENTIAL` 失败,而不是退回去用环境里碰巧存在的某个不相干的 key 计费。 -引用解析自 `$DSH_HOME/.env`(模型页的密钥输入框写的就是它),没有挂载凭据服务时则直接读同名环境变量。一份凭据供该路由上的所有模型使用。 +在 `dsh` 下,引用依次从继承环境、模型页的 `$DSH_HOME/.credentials.yaml` 存储、调用目录的 `.env` 和 `$DSH_HOME/.env` 解析。未挂载凭据服务时,引用只读取同名环境变量。一份凭据供该路由上的所有模型使用。 ## 让 agent 用上新提供方 diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index e284bdca7b..9ba9494c1c 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -68,12 +68,10 @@ - id: settings name: '@deepseek-ai/dsh-settings-local' - # Credential store: the live process environment over `$DSH_HOME/.env` - # (owner-only file, hot-reloaded). Adapters resolve their key references - # through it at each request, so no key is inlined in this file. The web - # Models page's key inputs write it through `credentials.set`; nothing hoists - # the document into the process environment, which would make every stored key - # read as an unrotatable ambient override. + # Credential sources: inherited environment over the managed + # `$DSH_HOME/.credentials.yaml`, with project and user `.env` fallbacks. + # Adapters resolve references per request; the Models page writes only the + # managed document, which is never materialized into the process environment. - id: credentials name: '@deepseek-ai/dsh-credentials-local' diff --git a/packages/client/ui-models/README.i18n.yaml b/packages/client/ui-models/README.i18n.yaml index 6c4c0e6aa0..edce8ce144 100644 --- a/packages/client/ui-models/README.i18n.yaml +++ b/packages/client/ui-models/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/client/ui-models/README.md -README.md: 80ae642ec9d6f91c78af041dda0b201959309577 -README.zh.md: 4236c8fec4f6d5e51363095d790944af9c08092a +README.md: 06c60b8bf6e16f3aeab422b12851cf7d39b13ab6 +README.zh.md: 5ff458820a5da225a0ebd05e91f3e55a8cb764b8 diff --git a/packages/client/ui-models/README.md b/packages/client/ui-models/README.md index 80ae642ec9..06c60b8bf6 100644 --- a/packages/client/ui-models/README.md +++ b/packages/client/ui-models/README.md @@ -4,11 +4,11 @@ English | [中文](README.zh.md) Models settings plugin: the provider configuration page and official-DeepSeek conditional onboarding step. It joins three wire domains into one shared snapshot — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time, without presenting route liveness as provider status. -Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The pi-ai card additionally edits that route's **model list** and can ask the provider what it serves. A row labels API-key state with a green solid dot only when a literal key or referenced credential is confirmed configured, and with a red solid dot only when a named reference is confirmed missing; reference-free provider-native authentication and unavailable credential enrichment remain unmarked. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. Leaving a new pi-ai provider's key blank saves a reference-free profile and therefore preserves provider-native authentication such as the Bedrock credential chain or Vertex ADC. A successful Apply emits a local accessible status message without echoing secret material. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), `reasoningEffort` (deepseek) or `reasoning` (pi-ai), and each adapter's model catalog. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`; existing fields outside that curated set survive edits, while every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its localized confirmation dialog names the provider in the title, description, and final action. +Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The pi-ai card additionally edits that route's **model list** and can ask the provider what it serves. A row labels API-key state with a green solid dot only when a referenced credential is confirmed configured, and with a red solid dot only when a named reference is confirmed missing; reference-free provider-native authentication and unavailable credential enrichment remain unmarked. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. Leaving a new pi-ai provider's key blank saves a reference-free profile and therefore preserves provider-native authentication such as the Bedrock credential chain or Vertex ADC. A successful Apply emits a local accessible status message without echoing secret material. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), `reasoningEffort` (deepseek) or `reasoning` (pi-ai), and each adapter's model catalog. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`; existing fields outside that curated set survive edits, while every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its localized confirmation dialog names the provider in the title, description, and final action. -The DeepSeek step projects `deepseek-official` readiness from that same joined snapshot after earlier onboarding pages complete. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured literal `apiKey` secret sidecar or configured credential reference completes the step without rendering, including a read-only launch-environment credential. Only a mounted, active adapter with a missing writable reference shows the page that opens Settings on Models, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering so onboarding cannot block the product; Models remains the diagnostic surface. +The DeepSeek step projects `deepseek-official` readiness from that same joined snapshot after earlier onboarding pages complete. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured credential reference completes the step without rendering, including a read-only launch-environment credential. Only a mounted, active adapter with a missing writable reference shows the page that opens Settings on Models, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering so onboarding cannot block the product; Models remains the diagnostic surface. -Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, the same shape the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. A typed API key is judged on its own field the same way: after trimming, it must be non-empty and every character must be printable ASCII (`[\x21-\x7E]`), which is exactly what an HTTP header value can carry — the twin of `normalizeApiKey` in `@deepseek-ai/dsh-llm`, mirrored here because the source-plane split forbids importing it. A value shaped like a pasted `NAME=value` environment line or wrapped in matching quotes is refused as the same format failure; that paste-shape heuristic runs only in the browser, since a false positive in a resolver would leave the environment refusing the key as well. A field holding only whitespace fails rather than being silently dropped, while an empty field is not a failure at all: it means keep the stored key on an editor card, and authenticate some other way on a create card. A refused key blocks both the write and the endpoint interrogation, so the page never spends a round trip to be told what the field already says. Each settings write carries the card's current `revision`, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict`; after settings commit, the card adopts the returned redacted user subtree and revision before storing the credential, which makes a failed credential stage retry only that stage. Deletion removes a configured, writable credential only when the profile names the page's derived `_API_KEY` target, then unsets the profile; both operations are idempotent, and a partial failure remains in the identified confirmation dialog for retry. Environment credentials, custom references, and credentials whose target cannot be identified remain untouched. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling. +Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it mutates the fields it can see rather than rebuilding a section. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, the same shape the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. A typed API key is judged on its own field the same way: after trimming, it must be non-empty and every character must be printable ASCII (`[\x21-\x7E]`), which is exactly what an HTTP header value can carry — the twin of `normalizeApiKey` in `@deepseek-ai/dsh-llm`, mirrored here because the source-plane split forbids importing it. A value shaped like a pasted `NAME=value` environment line or wrapped in matching quotes is refused as the same format failure; that paste-shape heuristic runs only in the browser, since a false positive in a resolver would leave the environment refusing the key as well. A field holding only whitespace fails rather than being silently dropped, while an empty field is not a failure at all: it means keep the stored key on an editor card, and authenticate some other way on a create card. A refused key blocks both the write and the endpoint interrogation, so the page never spends a round trip to be told what the field already says. Each settings write carries the card's current `revision`, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict`; after settings commit, the card adopts the returned redacted user subtree and revision before storing the credential, which makes a failed credential stage retry only that stage. Deletion removes a configured, writable credential only when the profile names the page's derived `_API_KEY` target, then unsets the profile; both operations are idempotent, and a partial failure remains in the identified confirmation dialog for retry. Environment credentials, custom references, and credentials whose target cannot be identified remain untouched. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling. ## Model list and endpoint interrogation diff --git a/packages/client/ui-models/README.zh.md b/packages/client/ui-models/README.zh.md index 4236c8fec4..5ff458820a 100644 --- a/packages/client/ui-models/README.zh.md +++ b/packages/client/ui-models/README.zh.md @@ -4,11 +4,11 @@ 模型设置插件:提供方配置页和按条件显示的 DeepSeek 官方首次使用引导步骤。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片,且不把路由存活状态呈现为提供方状态。 -行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。pi-ai 卡片还会编辑该路由的**模型列表**,并可以询问提供方它服务什么。只有确认字面密钥或引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。无引用的提供方原生认证以及无法取得凭据补充信息时都不显示状态点。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供方留空密钥会保存一个不带引用的 profile,因此能保留提供方原生认证,例如 Bedrock 凭据链或 Vertex ADC。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),另有 `reasoningEffort`(deepseek)或 `reasoning`(pi-ai),以及各适配器自己的模型目录。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),其本地化确认对话框会在标题、说明和最终操作中点名该提供方。 +行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。pi-ai 卡片还会编辑该路由的**模型列表**,并可以询问提供方它服务什么。只有确认引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。无引用的提供方原生认证以及无法取得凭据补充信息时都不显示状态点。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供方留空密钥会保存一个不带引用的 profile,因此能保留提供方原生认证,例如 Bedrock 凭据链或 Vertex ADC。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),另有 `reasoningEffort`(deepseek)或 `reasoning`(pi-ai),以及各适配器自己的模型目录。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),其本地化确认对话框会在标题、说明和最终操作中点名该提供方。 -前序首次使用引导页面完成后,DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,或凭据引用已配置,该步骤会直接完成而不渲染,其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置/凭据能力不可用时,该步骤均不渲染并直接完成,以免首次使用引导阻塞产品;Models 页仍是诊断界面。 +前序首次使用引导页面完成后,DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。凭据引用已配置时,该步骤会直接完成而不渲染,其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置/凭据能力不可用时,该步骤均不渲染并直接完成,以免首次使用引导阻塞产品;Models 页仍是诊断界面。 -每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它点名自己看得见的字段,而不是重建分节:一个它从未收到过的已存字面机密不会被任何 op 提及,也就得以留存。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,与 pi-ai 提供方表单采用的形态相同。两项容量都按数值键入,可带十进制的 `K` 或 `M` 后缀(`256K`、`1M`;`1M` 即 1000K),存储为纯数值,回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称,以及无法读取、非正数或非整数的容量都会在写入前失败。键入的 API 密钥同样在它自己的字段上被判定:trim 之后必须非空,且每个字符都是可打印 ASCII(`[\x21-\x7E]`)——这正是 HTTP 标头值所能承载的范围,是 `@deepseek-ai/dsh-llm` 中 `normalizeApiKey` 的孪生体,因源码平面分割禁止直接引入而在此镜像。形如整行粘贴的 `NAME=value` 环境变量或首尾成对引号包裹的值,会以同一条格式失败被拒绝;该粘贴形状启发式只在浏览器中运行,因为 resolver 中的一次误判会连带让环境变量这条路也拒绝该密钥。只含空白的输入框会失败而不是被静默丢弃;留空则完全不是失败:在编辑卡片上意味着保持已存储的密钥,在新建卡片上则意味着以其他方式鉴权。被拒绝的密钥会同时拦截写入与端点探测,因此页面不会白花一次往返去换取字段上已经写明的答案。每次 settings 写入都携带卡片当前的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝;settings 提交成功后,卡片会在存储凭据前采用响应返回的脱敏用户子树与 revision,因此凭据阶段失败时,重试只会重复该阶段。删除操作只会在 profile 指向页面派生的 `_API_KEY` 目标时清除已配置且可写的凭据,随后取消设置 profile;两项操作都具备幂等性,部分失败会停留在点名目标的确认对话框中供重试。环境凭据、自定义引用和无法识别目标的凭据保持不变。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。 +每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它只修改自己看得见的字段,而不重建分节。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,与 pi-ai 提供方表单采用的形态相同。两项容量都按数值键入,可带十进制的 `K` 或 `M` 后缀(`256K`、`1M`;`1M` 即 1000K),存储为纯数值,回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称,以及无法读取、非正数或非整数的容量都会在写入前失败。键入的 API 密钥同样在它自己的字段上被判定:trim 之后必须非空,且每个字符都是可打印 ASCII(`[\x21-\x7E]`)——这正是 HTTP 标头值所能承载的范围,是 `@deepseek-ai/dsh-llm` 中 `normalizeApiKey` 的孪生体,因源码平面分割禁止直接引入而在此镜像。形如整行粘贴的 `NAME=value` 环境变量或首尾成对引号包裹的值,会以同一条格式失败被拒绝;该粘贴形状启发式只在浏览器中运行,因为 resolver 中的一次误判会连带让环境变量这条路也拒绝该密钥。只含空白的输入框会失败而不是被静默丢弃;留空则完全不是失败:在编辑卡片上意味着保持已存储的密钥,在新建卡片上则意味着以其他方式鉴权。被拒绝的密钥会同时拦截写入与端点探测,因此页面不会白花一次往返去换取字段上已经写明的答案。每次 settings 写入都携带卡片当前的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝;settings 提交成功后,卡片会在存储凭据前采用响应返回的脱敏用户子树与 revision,因此凭据阶段失败时,重试只会重复该阶段。删除操作只会在 profile 指向页面派生的 `_API_KEY` 目标时清除已配置且可写的凭据,随后取消设置 profile;两项操作都具备幂等性,部分失败会停留在点名目标的确认对话框中供重试。环境凭据、自定义引用和无法识别目标的凭据保持不变。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。 ## 模型列表与端点询问 diff --git a/packages/credentials/credentials-local/README.i18n.yaml b/packages/credentials/credentials-local/README.i18n.yaml index 0f4d11b397..07a3efd5c3 100644 --- a/packages/credentials/credentials-local/README.i18n.yaml +++ b/packages/credentials/credentials-local/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/credentials/credentials-local/README.md -README.md: 2841440a853ea6a7859cb72de38d75e2bc5a821c -README.zh.md: accd20154106845c911f3ebe46ae4c61e615caca +README.md: 8e95a890a8e38172cf8984653a01c59570f0061a +README.zh.md: 04ad07ae4e703ab0416d1d8f1bb6a6ff90adf337 diff --git a/packages/credentials/credentials-local/README.md b/packages/credentials/credentials-local/README.md index 2841440a85..8e95a890a8 100644 --- a/packages/credentials/credentials-local/README.md +++ b/packages/credentials/credentials-local/README.md @@ -13,7 +13,7 @@ File-backed [credentials](../credentials/README.md) provider: four layers, one h The launching environment wins because a per-run override (`DEEPSEEK_API_KEY=… dsh`, a CI secret, a container `-e`) is operator intent for this run — and because it cannot be edited from inside, it must be *visibly* read-only: `describe()` reports `source: 'env', writable: false`, and `set`/`unset` reject instead of writing a change the reader would never see. -Everything below it loses to the managed store, so a key written by the web page or TUI takes effect immediately even when an older key sits in a `.env`. Those two layers still resolve when nothing is stored, and `describe()` names them `project-env` or `user-env` with `writable: true` — storing a key replaces them as the effective source. +Everything below it loses to the managed store, so a key written by the Models page takes effect immediately even when an older key sits in a `.env`. Those two layers still resolve when nothing is stored, and `describe()` names them `project-env` or `user-env` with `writable: true` — storing a key replaces them as the effective source. Under the product CLI, resolution reads the launcher's frozen [environment snapshot](../../util/environment/README.md) rather than `process.env`: only the snapshot can say whether a value came from the launching shell or from a file. A composition the product CLI did not boot has the inherited environment as its only layer, which keeps embedders on the semantics they already had. diff --git a/packages/credentials/credentials-local/README.zh.md b/packages/credentials/credentials-local/README.zh.md index accd201541..04ad07ae4e 100644 --- a/packages/credentials/credentials-local/README.zh.md +++ b/packages/credentials/credentials-local/README.zh.md @@ -13,7 +13,7 @@ 启动环境优先,因为按次覆盖(`DEEPSEEK_API_KEY=… dsh`、CI 机密、容器 `-e`)代表本次运行的操作者意图——而它无法从进程内部修改,就必须*可见地*只读:`describe()` 报告 `source: 'env', writable: false`,`set`/`unset` 直接拒绝,而不是写下一个读取方永远看不到的变更。 -它之下的一切都输给受管存储,因此 Web 页面或 TUI 写入的密钥会立即生效,即使某个 `.env` 里还留着更旧的密钥。没有存储任何东西时这两层仍会解析,`describe()` 会把来源报告为 `project-env` 或 `user-env` 且 `writable: true`——存入一个密钥就会取代它们成为生效来源。 +它之下的一切都输给受管存储,因此 Models 页写入的密钥会立即生效,即使某个 `.env` 里还留着更旧的密钥。没有存储任何东西时这两层仍会解析,`describe()` 会把来源报告为 `project-env` 或 `user-env` 且 `writable: true`——存入一个密钥就会取代它们成为生效来源。 在产品 CLI(命令行界面)下,解析读取的是启动器冻结的[环境快照](../../util/environment/README.md)而不是 `process.env`:只有快照才说得清某个值来自启动 shell 还是来自某个文件。并非由产品 CLI 启动的组合只有继承环境这一层,这让嵌入方保持它们原有的语义。 diff --git a/packages/credentials/credentials-local/src/index.ts b/packages/credentials/credentials-local/src/index.ts index ea77458d12..e781c10f3e 100644 --- a/packages/credentials/credentials-local/src/index.ts +++ b/packages/credentials/credentials-local/src/index.ts @@ -13,13 +13,12 @@ * secret, or a container `-e` is this run's explicit intent; it cannot be * edited from inside, so it must be *visibly* read-only rather than silently * shadow writes. Everything below it loses to the managed store, so a key the - * web page or TUI writes takes effect immediately even when an older key sits - * in the user's `.env`. + * Models page writes takes effect immediately even when an older key sits in + * the user's `.env`. * * The invoking project may supply a key, because the product trusts the * project it is launched in. It ranks below the managed store, so a key stored - * through the web page or TUI is never displaced by one a checkout happens to - * carry. + * through the Models page is never displaced by one a checkout happens to carry. * * The file is the provider-managed writable source: every write re-reads the * document under a cross-process writer lock before patching only its own key diff --git a/packages/credentials/credentials/README.i18n.yaml b/packages/credentials/credentials/README.i18n.yaml index 10fe5f0ffe..beeeef0ffd 100644 --- a/packages/credentials/credentials/README.i18n.yaml +++ b/packages/credentials/credentials/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/credentials/credentials/README.md -README.md: 1c18c4762360ad081227b7097cd82ddab4fcdefc -README.zh.md: 751fb7c1e8326cef91b925c5f8b9f40d92e1bba6 +README.md: 95ef76d145727340d8135bf1d48babd6d8adb882 +README.zh.md: b3404858025d4ec53a76548c78c1808d2c858844 diff --git a/packages/credentials/credentials/README.md b/packages/credentials/credentials/README.md index 1c18c47623..95ef76d145 100644 --- a/packages/credentials/credentials/README.md +++ b/packages/credentials/credentials/README.md @@ -31,7 +31,7 @@ The shadowing rule on `set`/`unset` is deliberate fail-loud: when a read-only so ## Providers -[`dsh-credentials-local`](../credentials-local/README.md) layers the live process environment over a `$DSH_HOME/.env` file. The seam shape leaves room for keyring-, helper-command-, and KMS-backed providers; a remote settings provider never needs to carry secrets. +[`dsh-credentials-local`](../credentials-local/README.md) layers the inherited process environment over its managed `$DSH_HOME/.credentials.yaml` document, with the launcher's project and user `.env` layers as fallbacks. The seam shape leaves room for keyring-, helper-command-, and KMS-backed providers; a remote settings provider never needs to carry secrets. ## Model Experience diff --git a/packages/credentials/credentials/README.zh.md b/packages/credentials/credentials/README.zh.md index 751fb7c1e8..b340485802 100644 --- a/packages/credentials/credentials/README.zh.md +++ b/packages/credentials/credentials/README.zh.md @@ -31,7 +31,7 @@ await ctx.credentials.unset(ref) // no-op when absent; s ## Providers -[`dsh-credentials-local`](../credentials-local/README.md) 把活跃进程环境叠加在 `$DSH_HOME/.env` 文件之上。seam 形状为 keyring、辅助命令、KMS 后端的 provider 留好了位置;远端 settings provider 永远不必携带机密。 +[`dsh-credentials-local`](../credentials-local/README.md) 把继承的进程环境叠加在其受管 `$DSH_HOME/.credentials.yaml` 文档之上,并以启动器的项目和用户 `.env` 层作为后备。seam 形状为 keyring、辅助命令、KMS 后端的 provider 留好了位置;远端 settings provider 永远不必携带机密。 ## Model Experience diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index bd322be07f..8b563b72b0 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: 0dcf15d6caf365a1f8e75088cb363eaa6560a6ec -README.zh.md: 79be5d320c0f4411f7cf8a0bd72c887048929dcb +README.md: 141a1a6250a69982564a9277e2cc97d8009d30e3 +README.zh.md: d2312bd2f4716500d8458b7806f6479e2e411937 diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 0dcf15d6ca..141a1a6250 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -8,7 +8,7 @@ The package root exposes the Cordis plugin contract, `PiAiAdapter`, and `support ## Config -Configure credentials, the model catalog, 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 leaves the route unauthenticated, which for an installed catalog route means 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. One credential serves every model on its route. +Configure credentials, the model catalog, and deployment-specific transport settings per provider, keyed by the provider route itself. `apiKeyEnv` is a credential *reference* resolved per request, so no secret enters this file. Omitting it leaves the route unauthenticated, which for an installed catalog route means 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. One credential serves every model on its route. ```yaml - id: llm @@ -67,7 +67,7 @@ Resolution still fails loud, naming the offending route and model, when a route 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. Every key is trimmed and format-checked before use — a literal `apiKey` when profiles resolve (plugin load, or the next settings snapshot), a value `apiKeyEnv` resolves at request time — so a value no HTTP header can carry is refused there instead of surfacing as an opaque `fetch` `TypeError`; the request-time refusal throws `LlmError('INVALID_CREDENTIAL')` naming the failing route and credential reference but never any part of the key. 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 section this adapter could not serve is refused where it is written — the registered `validate` resolves the whole profile set, so `ctx.settings.mutate` rejects with the resolver's own error (the wire surface reports it as `settings-rejected`) and nothing is stored. A stored section that becomes unserviceable some other way — an external edit of `settings.yaml` — keeps the namespace's last good value at the settings seam and warns. The entry config itself still fails plugin load, and a route the llm registry refuses (one another adapter family already owns) is logged while the previously registered routes keep serving. +Credentials resolve per stream call through `apiKeyEnv` and the optional `ctx.credentials` seam; without that seam, the adapter reads exactly the referenced environment variable. A profile naming no credential at all — and only that case — defers to pi-ai's ambient discovery. Every resolved key is trimmed and format-checked before use, so a value no HTTP header can carry is refused instead of surfacing as an opaque `fetch` `TypeError`; the refusal throws `LlmError('INVALID_CREDENTIAL')` naming the failing route and credential reference but never any part of the key. 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 section this adapter could not serve is refused where it is written — the registered `validate` resolves the whole profile set, so `ctx.settings.mutate` rejects with the resolver's own error (the wire surface reports it as `settings-rejected`) and nothing is stored. A stored section that becomes unserviceable some other way — an external edit of `settings.yaml` — keeps the namespace's last good value at the settings seam and warns. The entry config itself still fails plugin load, and a route the llm registry refuses (one another adapter family already owns) is logged while the previously registered routes keep serving. The adapter exposes each configured route's models through `ctx.llm.listModels(provider)`. This is provider-neutral selector metadata read from the same pi-ai `Models` collection the request path uses, 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, configured output cap, and selectable thinking levels, keeping authoritative metadata on the route-owning adapter rather than its consumers. A model's **configured** `maxTokens` becomes the seam's `defaultMaxTokens`, so a request that names no output cap carries the one the deployment chose; a value inherited from the installed catalog is the model's output *capability* and never becomes a request default on its own. @@ -75,7 +75,7 @@ A model that carries reasoning metadata exposes pi-ai's ordered `getSupportedThi A model **without** that metadata — every hand-declared one, and a catalog model pi-ai marks as non-reasoning — exposes no `reasoning` at all. pi-ai reports such a model as supporting the single level `off`, but `off` is translated to *omitting* the reasoning option, which is byte-for-byte the request that naming no effort already produces: selecting it could not disable anything, so a provider whose own default is to think would keep thinking with `off` shown as selected. Reporting the capability as unavailable leaves a surface offering the provider's default and nothing that misrepresents it. 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 `apiKey`, `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `defaultContextWindow`, `defaultMaxTokens`, `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 `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `defaultContextWindow`, `defaultMaxTokens`, `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`. @@ -85,7 +85,7 @@ The plugin offers `ctx.llm.registerModelDiscovery('llm-pi-ai', …)`, which answ A request naming a route the **installed catalog ships is answered from that catalog**, with no network call: pi-ai's registry is the authoritative list for its own providers, and it carries the context windows and output caps a listing endpoint would not disclose. Such a route needs no `baseURL` at all. Only a route the catalog does not describe — a gateway, a self-hosted server — is interrogated over the wire, and one that names no endpoint is told to set one or enter its models by hand. -A draft carries the credential the user typed, if any; a route that already stored one shows a configuration surface only a redacted descriptor, so the interrogation supplies that route's own credential — resolved exactly as a request to it would, `apiKey` then `apiKeyEnv` — rather than going out unauthenticated and reporting the endpoint's 401 as a wrong key. A typed key wins, being the one under test. Resolution happens only on the path that reaches the network, so a catalog route answers without touching credentials at all. A supplied or stored probe key is trimmed and format-checked the same way, so a value no HTTP header can carry is refused immediately as `LlmError('INVALID_CREDENTIAL')` instead of reaching `fetch`, where it would surface as an opaque `ByteString` failure indistinguishable from an unreachable endpoint. +A draft carries the credential the user typed, if any; a route that already stored one shows a configuration surface only a redacted descriptor, so the interrogation resolves that route's `apiKeyEnv` rather than going out unauthenticated and reporting the endpoint's 401 as a wrong key. A typed key wins, being the one under test. Resolution happens only on the path that reaches the network, so a catalog route answers without touching credentials at all. A supplied or stored probe key is trimmed and format-checked the same way, so a value no HTTP header can carry is refused immediately as `LlmError('INVALID_CREDENTIAL')` instead of reaching `fetch`, where it would surface as an opaque `ByteString` failure indistinguishable from an unreachable endpoint. Interrogation reads `openai-completions` and `openai-responses`, whose `GET /models` shape with bearer auth is the one a gateway, a self-hosted server, and the official endpoints all agree on. Azure is excluded despite its OpenAI lineage — it authenticates with an `api-key` header and requires an `api-version` query — and Codex uses OAuth; every other protocol answers `DISCOVERY_UNSUPPORTED` so the surface falls back to hand-entry instead of an authentication failure being reported as a provider with no models. The `baseURL` is treated as a prefix rather than a URL to resolve against, so a deployment path such as `https://gateway.example/openai/v1` keeps its segments. @@ -155,7 +155,7 @@ Recorded response content appends to the next request and does not invalidate it - **`headers` can carry a credential the redactor never sees** — the profile's `headers` dict is plain strings, so `Authorization` or `api-key` set there is returned verbatim by a redacted `describe()` and rendered by any configuration UI. Store credentials as `apiKeyEnv` references; making the dict write-only is deferred with the rest of the [wire-boundary work](../llm/README.md#known-limitations-and-deferred-work). - **A route's catalog never refreshes itself** — the catalog is whatever `settings.yaml` says, so a model list is only as current as its last edit. Nothing here queries a provider for the models it serves; a route gains a model when someone writes one. - **One wire protocol per route** — `api` applies to the whole route, so a mixed-protocol catalog route (an OpenAI-style catalog spanning Responses and Chat Completions) cannot host a model of the other protocol, and adding a model such a route does not describe requires naming `api` and moving every model onto it. Splitting the provider across two route keys is the workaround. -- **An unauthenticated route depends on its protocol** — naming no credential resolves the route as configured-but-keyless, but pi-ai's OpenAI-compatible implementation still requires an API key or an `Authorization` header, so a keyless local server needs a placeholder `apiKey` or an `Authorization` entry in `headers`. +- **An unauthenticated route depends on its protocol** — naming no credential resolves the route as configured-but-keyless, but pi-ai's OpenAI-compatible implementation still requires an API key or an `Authorization` header, so a keyless local server needs a placeholder credential referenced by `apiKeyEnv` or an `Authorization` entry in `headers`. - **`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. - **Provider HTTP status is unavailable** — pi-ai error events do not expose a stable HTTP status across providers; failures expose only stable harness error codes. diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index 79be5d320c..d2312bd2f4 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -8,7 +8,7 @@ ## 配置 -按提供方配置凭据、模型 catalog 与部署特定传输设置,并以提供方路由本身为键。优先使用 `apiKeyEnv`——按请求解析的凭据*引用*——而非字面 `apiKey`,让机密不进入该文件。**两者**都省略,才会让该路由处于未认证状态;对已安装 catalog 路由而言,这意味着交给 pi-ai 的提供方原生环境发现。已配置却解析不出任何值的引用则相反,会让请求以 `MISSING_CREDENTIAL` 失败,因为放行下去就会用环境里恰好持有的某个无关密钥完成认证。一条凭据服务该路由下的全部模型。 +按提供方配置凭据、模型 catalog 与部署特定传输设置,并以提供方路由本身为键。`apiKeyEnv` 是按请求解析的凭据*引用*,因此机密不进入该文件。省略它会让该路由处于未认证状态;对已安装 catalog 路由而言,这意味着交给 pi-ai 的提供方原生环境发现。已配置却解析不出任何值的引用则相反,会让请求以 `MISSING_CREDENTIAL` 失败,因为放行下去就会用环境里恰好持有的某个无关密钥完成认证。一条凭据服务该路由下的全部模型。 ```yaml - id: llm @@ -67,7 +67,7 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩 适配器经由一个 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 的环境发现。每个密钥在使用前都会被去除首尾空白并校验格式——字面 `apiKey` 在 profile 解析时(插件加载,或下一次 settings 快照)校验,`apiKeyEnv` 解析出的值则在请求时校验——因此 HTTP 标头无法承载的值会在这一步被拒绝,而不是以语义不明的 `fetch` `TypeError` 形式浮现;请求时的拒绝会抛出 `LlmError('INVALID_CREDENTIAL')`,点名失败的路由与凭据引用,但绝不透露密钥的任何部分。路由集合与每条路由捕获的重试策略是注册级事实:两者任一变化时,插件都会原子地替换自己的注册(同一适配器实例,候选集合先经校验),因此某条路由若已被另一适配器占有,先前的路由会继续服务,而改回可用配置时注册会重新生效。提供方键的顺序绝不算作变化。本适配器无法服务的分节会在写入处被拒——注册的 `validate` 会解析整份 profile 集合,因此 `ctx.settings.mutate` 以 resolver 自身的错误拒绝(协议面将其报为 `settings-rejected`),什么都不会存储。已存储分节若因其他途径变得不可服务——比如外部编辑了 `settings.yaml`——则由 settings seam 保留该 namespace 最后可用的值并告警。entry 配置本身仍会使插件加载失败;而 llm 注册表拒绝的路由(已被另一适配器族占有的那种)会被记录下来,先前注册的路由继续服务。 +凭据在每次 stream 调用时通过 `apiKeyEnv` 与可选的 `ctx.credentials` seam 解析;未挂载该 seam 时,适配器只读取该引用指向的环境变量。只有完全没有点名任何凭据的 profile——仅限这一种情况——才交给 pi-ai 的环境发现。每个解析出的密钥都会在使用前去除首尾空白并校验格式,因此 HTTP 标头无法承载的值会被拒绝,而不是以语义不明的 `fetch` `TypeError` 形式浮现;这种拒绝会抛出 `LlmError('INVALID_CREDENTIAL')`,点名失败的路由与凭据引用,但绝不透露密钥的任何部分。路由集合与每条路由捕获的重试策略是注册级事实:两者任一变化时,插件都会原子地替换自己的注册(同一适配器实例,候选集合先经校验),因此某条路由若已被另一适配器占有,先前的路由会继续服务,而改回可用配置时注册会重新生效。提供方键的顺序绝不算作变化。本适配器无法服务的分节会在写入处被拒——注册的 `validate` 会解析整份 profile 集合,因此 `ctx.settings.mutate` 以 resolver 自身的错误拒绝(协议面将其报为 `settings-rejected`),什么都不会存储。已存储分节若因其他途径变得不可服务——比如外部编辑了 `settings.yaml`——则由 settings seam 保留该 namespace 最后可用的值并告警。entry 配置本身仍会使插件加载失败;而 llm 注册表拒绝的路由(已被另一适配器族占有的那种)会被记录下来,先前注册的路由继续服务。 适配器通过 `ctx.llm.listModels(provider)` 公开每条已配置路由的模型。这是从请求路径所用的同一个 pi-ai `Models` 集合读取的提供方无关 selector 元数据,因此发现不会创建第二个模型注册表。`ctx.llm.resolveModelInfo(provider, model)` 会执行一次精确 descriptor 查找,并返回其身份、上下文窗口、已配置输出上限和可选思考级别,让权威元数据保留在拥有路由的适配器上,而非消费方。模型**已配置**的 `maxTokens` 会成为 seam 的 `defaultMaxTokens`,因此未点名输出上限的请求会携带部署选定的那一个;而从已安装 catalog 继承来的值是模型的输出**能力**,绝不会自行变成请求默认值。 @@ -75,7 +75,7 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩 **没有**这份元数据的模型——每一个手工声明的模型,以及 pi-ai 标记为不具备推理能力的 catalog 模型——完全不公开 `reasoning`。pi-ai 会把这类模型报告为只支持 `off` 一档,但 `off` 会被翻译成*省略* reasoning 选项,而那与「不点名任何档位」产出的请求逐字节相同:选它关不掉任何东西,于是自身默认就在思考的提供方,会在界面显示 `off` 被选中的同时继续思考。把该能力报告为不可用,界面就只剩提供方默认这一项,不会再出现自相矛盾的控件。配置 profile 的 `reasoning` 值(包括 `off`)在存在时是部署默认值;省略它会保留提供方默认值。每次请求的 `GenerateOptions.reasoningEffort` 优先;任何未出现在确切模型能力中的显式值都会在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败,而不会被自动调整。pi-ai 的通用流选项通过省略 `reasoning` 表示 `off`。 -受支持的 profile 字段是 `apiKey`、`apiKeyEnv`、`displayName`、`api`、`baseURL`、`models`、`defaultContextWindow`、`defaultMaxTokens`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs` 和 `retryPolicy`。每个 profile 的可选重试策略都会与该提供方路由一同捕获;省略时使用有界的常规默认值。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。若已配置标头中有同名项,则以 Harness 应用归因为准。 +受支持的 profile 字段是 `apiKeyEnv`、`displayName`、`api`、`baseURL`、`models`、`defaultContextWindow`、`defaultMaxTokens`、`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`。 @@ -85,7 +85,7 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩 点名了**已安装 catalog 所提供路由**的请求,直接由该 catalog 作答,完全不联网:pi-ai 的注册表才是它自家提供方的权威列表,且携带列表端点不会公布的上下文窗口与输出上限。这类路由根本不需要 `baseURL`。只有 catalog 未描述的路由——网关、自建服务——才会经协议层询问;若它也没给端点,则会被告知去设置一个或手工填写模型。 -草稿携带的是用户当下键入的凭据(如果有);已经存好凭据的路由,在配置界面上只呈现一个脱敏描述符,因此询问会自行取用该路由的凭据——解析方式与向它发请求时完全一致,先 `apiKey` 后 `apiKeyEnv`——而不是不带认证发出去、再把端点的 401 报成密钥不对。键入的密钥优先,因为那正是被测试的那一把。解析只发生在真正要联网的路径上,因此 catalog 路由作答时完全不会触碰凭据。用户提供或已存储的探测密钥也会经过同样的去除空白与格式校验:HTTP 标头无法承载的值会被立即以 `LlmError('INVALID_CREDENTIAL')` 拒绝,而不会传到 `fetch`——否则会呈现为一个和端点不可达难以区分的、语义不明的 `ByteString` 失败。 +草稿携带的是用户当下键入的凭据(如果有);已经存好凭据的路由,在配置界面上只呈现一个脱敏描述符,因此询问会解析该路由的 `apiKeyEnv`,而不是不带认证发出去、再把端点的 401 报成密钥不对。键入的密钥优先,因为那正是被测试的那一把。解析只发生在真正要联网的路径上,因此 catalog 路由作答时完全不会触碰凭据。用户提供或已存储的探测密钥也会经过同样的去除空白与格式校验:HTTP 标头无法承载的值会被立即以 `LlmError('INVALID_CREDENTIAL')` 拒绝,而不会传到 `fetch`——否则会呈现为一个和端点不可达难以区分的、语义不明的 `ByteString` 失败。 询问只读 `openai-completions` 与 `openai-responses`,它们「`GET /models` + bearer 认证」的形状是网关、自建服务与官方端点三方一致认可的那一种。Azure 尽管出身 OpenAI 也被排除——它用 `api-key` 标头认证并要求 `api-version` 查询参数——Codex 则走 OAuth;其余协议一律以 `DISCOVERY_UNSUPPORTED` 回答,让界面回退到手工填写,而不是把认证失败报成一个没有模型的提供方。`baseURL` 按前缀而非待解析 URL 处理,因此 `https://gateway.example/openai/v1` 这类部署路径会保留其路径段。 @@ -155,7 +155,7 @@ pi-ai 事件会变为 harness 推理、文本、工具调用、usage 与 finish - **`headers` 可能承载一条脱敏器看不见的凭据**:profile 的 `headers` 是纯字符串字典,因此设在其中的 `Authorization` 或 `api-key` 会被脱敏后的 `describe()` 原样返回,并被任何配置 UI 渲染出来。请把凭据存为 `apiKeyEnv` 引用;把该字典整体改为只写与其余[协议边界工作](../llm/README.md#known-limitations-and-deferred-work)一并暂缓。 - **路由的 catalog 不会自我刷新**:catalog 就是 `settings.yaml` 所写的内容,因此模型列表的新鲜度只到最近一次编辑为止。这里没有任何环节会去问提供方它服务哪些模型;路由要多一个模型,得有人写进去。 - **每条路由只有一种协议格式**:`api` 作用于整条路由,因此混合协议的 catalog 路由(跨 Responses 与 Chat Completions 的 OpenAI 式 catalog)无法承载另一种协议的模型,向这类路由添加它未描述的模型必须点名 `api` 并把全部模型一起迁过去。把该提供方拆成两个路由键是变通办法。 -- **未认证路由取决于其协议**:不点名凭据会让路由解析为「已配置但无密钥」,但 pi-ai 的 OpenAI 兼容实现仍要求 API key 或 `Authorization` 标头,因此无鉴权的本地服务需要一个占位 `apiKey`,或在 `headers` 中给出 `Authorization` 条目。 +- **未认证路由取决于其协议**:不点名凭据会让路由解析为「已配置但无密钥」,但 pi-ai 的 OpenAI 兼容实现仍要求 API key 或 `Authorization` 标头,因此无鉴权的本地服务需要一个由 `apiKeyEnv` 引用的占位凭据,或在 `headers` 中给出 `Authorization` 条目。 - **不支持 `GenerateOptions.stop`**:pi-ai 的通用流选项无法保证所有提供方都支持 stop sequence,因此适配器会拒绝该字段。 - **历史中的 `system` 消息使用 pi-ai 通用上下文转换**:提供方特定位置由 pi-ai 决定,而非由 harness 拥有的协议覆盖决定。 - **无法获取提供方 HTTP 状态**:pi-ai 错误事件不会在所有提供方上公开稳定 HTTP 状态;失败只公开稳定 harness 错误 code。 diff --git a/packages/llm/llm-retry/README.i18n.yaml b/packages/llm/llm-retry/README.i18n.yaml index b1157f806f..65e0c911ed 100644 --- a/packages/llm/llm-retry/README.i18n.yaml +++ b/packages/llm/llm-retry/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-retry/README.md -README.md: 23b55a30989cc51d4dd9076b61b6595452b0abd0 -README.zh.md: 267ef12a87561fd8effef726a781e505225baf03 +README.md: e6e56ec44032d714393c6fcc1c42d7271017a294 +README.zh.md: b7ce8bee4acd2c4f7c88870745dff96ec5695435 diff --git a/packages/llm/llm-retry/README.md b/packages/llm/llm-retry/README.md index 23b55a3098..e6e56ec440 100644 --- a/packages/llm/llm-retry/README.md +++ b/packages/llm/llm-retry/README.md @@ -15,7 +15,7 @@ The separately published `./invariant` companion checks that every retry record ```yaml - name: '@deepseek-ai/dsh-llm-deepseek' config: - apiKey: !!js process.env.DEEPSEEK_API_KEY + apiKeyEnv: DEEPSEEK_API_KEY retryPolicy: mode: always backoff: diff --git a/packages/llm/llm-retry/README.zh.md b/packages/llm/llm-retry/README.zh.md index 267ef12a87..b7ce8bee4a 100644 --- a/packages/llm/llm-retry/README.zh.md +++ b/packages/llm/llm-retry/README.zh.md @@ -15,7 +15,7 @@ ```yaml - name: '@deepseek-ai/dsh-llm-deepseek' config: - apiKey: !!js process.env.DEEPSEEK_API_KEY + apiKeyEnv: DEEPSEEK_API_KEY retryPolicy: mode: always backoff: diff --git a/packages/ui/app-boot/README.i18n.yaml b/packages/ui/app-boot/README.i18n.yaml index 398ec6e923..1c15f51109 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: cdd78047b6ad71148c6ebeba598b63b4ae4cfa7b -README.zh.md: ee2b07884e68510e2b59b9f2c27053c263d15f1a +README.md: 9c2f9a8dac6b164cb23260e743eb2cdf1f29d3aa +README.zh.md: 8422a176e682a87d1e592d5140b719e628e7d8e7 diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index cdd78047b6..9c2f9a8dac 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -8,6 +8,7 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c |---|---| | `resolveConfigPath(path, snapshotMode, cwd?)` | Absolute config path; `snapshotMode === 'replay'` swaps a `cordis.yml`/`.yaml` basename for its sibling `cordis.snapshot.yml` | | `loadEnv(binName, dir?, warn?)` | Load the gitignored `.env` (Node `process.loadEnvFile`); absent file is fine, an unloadable one warns a single labelled line (default: stderr) | +| `loadLayeredEnv(binName, cwd?, warn?)` | Build the product CLI's frozen inherited > project `.env` > user `.env` snapshot, reject bootstrap-only file variables, and materialize accepted file values without replacing inherited ones | | `installFailLoud(binName, proc?, release?)` | Turn an unhandled boot or later Loader rejection into one labelled stderr line + `exit(1)`; the optional `release` teardown is awaited between the two (bounded by `FAIL_LOUD_RELEASE_TIMEOUT_MS`) so a terminal-owning surface restores the terminal before exit; returns the uninstaller | | `FAIL_LOUD_RELEASE_TIMEOUT_MS` | How long `installFailLoud` waits for its `release` hook; a wedged disposer delays the fatal exit, never cancels it | | `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber, reporting every unresolved plugin name as a Cordis startup failure | @@ -36,7 +37,7 @@ A profile is a directory under `$DSH_HOME/profiles/` (the Harness home res User-level machine-local preferences also live in the Harness home: -- **`.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 Web settings 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. +- **`.env`** — the product CLI's ordinary environment layers: the invoking directory's file outranks the Harness-home file, and both sit below the inherited environment. `loadLayeredEnv` snapshots each value's source, rejects bootstrap-only file variables, and materializes accepted values into `process.env` for Loader expressions and third-party libraries. Managed credentials live separately in [`.credentials.yaml`](../../credentials/credentials-local/README.md); a credential left in either `.env` remains a lower-priority fallback. - **`cordis.patch.yml`** (home level) and **`profiles//cordis.patch.yml`** — the user patch layers, applied after every bundle layer (per-profile first, then the home-level file, which therefore outranks it): 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 composed tree is a stderr warning. An empty or comments-only file throws (it parses to nothing, not to a list); disable the layer with `[]`. Long-lived surfaces keep `cordis.patch.yml` live through `watchUserPatches`; one-shot runs read only the startup value. The watcher targets the exact path even when the file or immediate parent does not exist, serializes bursts, and recomposes the user patches inside the caller's layer order (bundle layers below, overlay/flag patches above). A rejected read, parse, or Loader candidate leaves the last good tree running and the HMR service broadcasts `hmr/config-update-failed(filename, Error)` after logging it; observer failures are contained. Disposing the context closes the watcher and drains an active refresh. @@ -53,5 +54,5 @@ No direct invalidation from `boot()`; a consumer that calls `addHarnessSourceSec - **Bare package specifiers depend on Loader internals** — production bins need Loader's optional native helper; an in-process caller without it must use resolvable relative/file specifiers or provide its own module-resolution hook. - **Snapshot replay swapping is basename-specific** — only a config ending in `cordis.yml` or `cordis.yaml` maps to the sibling `cordis.snapshot.yml`; custom config names require caller-managed selection. -- **Environment loading is cwd-scoped and optional** — the helper loads one `.env` file and warns on failure; it does not search parents, merge profiles, or validate required variables. +- **Environment discovery is launch-scoped** — `loadLayeredEnv` reads only the invocation directory and Harness home once; it does not search parents or follow a workspace selected later. `loadEnv` remains the one-directory helper for non-product bins. - **User patch layers are patch-shaped** — an id-targeted patch replaces the entry's whole `config` rather than deep-merging, so a profile override restates the bundle fields it keeps. diff --git a/packages/ui/app-boot/README.zh.md b/packages/ui/app-boot/README.zh.md index ee2b07884e..8422a176e6 100644 --- a/packages/ui/app-boot/README.zh.md +++ b/packages/ui/app-boot/README.zh.md @@ -8,6 +8,7 @@ |---|---| | `resolveConfigPath(path, snapshotMode, cwd?)` | 生成绝对配置路径;当 `snapshotMode === 'replay'` 时,把 basename 为 `cordis.yml`/`.yaml` 的文件替换为同级 `cordis.snapshot.yml` | | `loadEnv(binName, dir?, warn?)` | 加载已被 git 忽略的 `.env`(Node `process.loadEnvFile`);文件不存在不影响启动,文件无法加载时输出一行带标签的警告(默认写入 stderr) | +| `loadLayeredEnv(binName, cwd?, warn?)` | 构建产品 CLI(命令行界面)冻结的「继承环境 > 项目 `.env` > 用户 `.env`」快照,拒绝文件中的 bootstrap-only 变量,并在不替换继承值的前提下物化其余文件值 | | `installFailLoud(binName, proc?, release?)` | 将启动期或后续未处理的 Loader rejection 转换为一行带标签的 stderr 消息并执行 `exit(1)`;两者之间会等待可选的 `release` 拆卸回调(以 `FAIL_LOUD_RELEASE_TIMEOUT_MS` 为上限),使持有终端的界面能在退出前恢复终端;返回卸载函数 | | `FAIL_LOUD_RELEASE_TIMEOUT_MS` | `installFailLoud` 等待其 `release` 回调的时长;卡住的 disposer 只会延迟致命退出,而不会取消它 | | `assertEntriesLoaded(ctx, binName)` | 树结算后,如果其中存在已启用但没有 fiber 的条目,则抛出异常,并以 Cordis 启动故障的形式报告每个未解析插件的名称 | @@ -36,7 +37,7 @@ profile 是位于 `$DSH_HOME/profiles/` 下的目录(Harness home 由 [` 用户级的机器本地偏好同样位于 Harness home 中: -- **`.env`**:[`dsh-credentials-local`](../../credentials/credentials-local/README.md) 的凭据存储,只由该 provider 读取。没有任何表层会把它提升进 `process.env`:那样做会让每个已存密钥在下次运行时看起来都像只读的启动时覆盖,从而阻断从 Web 设置页面轮换密钥。环境层次由环境中的值与调用目录的 `.env` 构成(由 bin 加载;`process.loadEnvFile` 从不覆盖已有值),没有凭据 provider 的组合仍然只从这两者解析密钥。 +- **`.env`**:产品 CLI 的普通环境层;调用目录的文件优先于 Harness home 的文件,两者都低于继承环境。`loadLayeredEnv` 记录每个值的来源,拒绝文件中的 bootstrap-only 变量,并把其余值物化进 `process.env`,供 Loader 表达式和第三方库使用。受管凭据另存于 [`.credentials.yaml`](../../credentials/credentials-local/README.md);留在任一 `.env` 中的凭据仍是低优先级后备值。 - **`cordis.patch.yml`**(home 级)与 **`profiles//cordis.patch.yml`**:用户 patch 层,应用在所有组合包层之后(先应用逐 profile 的文件,再应用 home 级文件,因此后者优先级更高):按 id 定位的 patch 会替换对应条目的整个 `config`(未改字段也要重述),`insert` 会添加条目,`!!js` 表达式则在挂载时插值。如果 patch 指定的条目 id 不在组合后的树中,则输出一条 stderr 警告。空文件或仅含注释的文件会抛出异常(其解析结果为空,而不是列表);如需禁用该层,请使用 `[]`。 长期运行的 surface 会持续应用 `cordis.patch.yml` 的变更,具体由 `watchUserPatches` 负责;一次性运行只读取启动时的值。即使该文件或其直接父目录不存在,watcher 仍会监视确切路径;它会串行处理突发变更,并按调用方的层次顺序重新组合用户 patch(组合包层在下、overlay/标志 patch 在上)。读取失败、解析失败或 Loader 候选被拒时,最后一个可用树会继续运行;HMR 服务记录错误后广播 `hmr/config-update-failed(filename, Error)`,并隔离 observer 失败。上下文 dispose 时会关闭 watcher,并等待进行中的刷新结束。 @@ -53,5 +54,5 @@ profile 是位于 `$DSH_HOME/profiles/` 下的目录(Harness home 由 [` - **裸包 specifier 依赖 Loader 内部机制**:生产 bin 需要 Loader 的可选原生 helper;没有该 helper 的进程内调用方必须使用可解析的相对/file specifier,或提供自己的模块解析钩子。 - **快照回放替换仅识别特定 basename**:只有以 `cordis.yml` 或 `cordis.yaml` 结尾的配置会映射到同级 `cordis.snapshot.yml`;自定义配置名称需要调用方自行选择。 -- **环境加载局限于 cwd 且为可选操作**:helper 只加载一个 `.env` 文件,并在失败时发出警告;它不会搜索父目录、合并 profile 或验证必需变量。 +- **环境发现以启动为界**:`loadLayeredEnv` 只读取一次调用目录与 Harness home 中的 `.env`;它不搜索父目录,也不跟随之后选择的 workspace。`loadEnv` 仍是非产品 bin 使用的单目录 helper。 - **用户 patch 层采用 patch 形式**:按 id 定位的 patch 会替换条目的整个 `config`,而不是深度合并,因此 profile 覆盖必须重述需要保留的组合包字段。 From ac154b2dfa1c174b062931a9ab57e8e3737a3b77 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 7 Aug 2026 21:41:22 +0800 Subject: [PATCH 18/27] test(cli): cover Harness-home credential loading in built entry Source-level environment and credential tests prove the individual loaders, but they do not prove that the published launcher runs them before Loader evaluates a shipped profile. Start the built dsh binary with the shipped base bundle and a test-only LLM probe. Put the endpoint in $DSH_HOME/.env, put the bearer token only in $DSH_HOME/.credentials.yaml, remove inherited DeepSeek overrides, and assert the mock request received both without leaking the token. This covers launch order, profile composition, the adapter, and the credential seam without a real API. --- apps/cli/package.json | 1 + apps/cli/tests/built-bin.e2e.ts | 91 ++++++++++++++++++++++++++++++++- pnpm-lock.yaml | 3 ++ 3 files changed, 93 insertions(+), 2 deletions(-) diff --git a/apps/cli/package.json b/apps/cli/package.json index 87677ce1f9..71b70b078d 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -39,6 +39,7 @@ "@deepseek-ai/dsh-frontend-static": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", + "@deepseek-ai/dsh-llm-mock-server": "workspace:^", "@deepseek-ai/dsh-loader-smoke": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index ede3d17134..22fe20883e 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -2,6 +2,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' +import { startMockLlmServer } from '@deepseek-ai/dsh-llm-mock-server' import { execa } from 'execa' import { afterEach, beforeEach, describe, expect, it } from 'vitest' @@ -13,14 +14,21 @@ const invalidProvider = fileURLToPath(new URL('./fixtures/invalid-provider.cordi async function runBuiltBin( args: readonly string[] = [], - env: Record = {}, + env: Readonly> = {}, + cwd?: string, ): Promise<{ stdout: string; code: number; stderr: string }> { + const childEnv = Object.fromEntries( + Object.entries({ ...process.env, ...env }) + .filter((entry): entry is [string, string] => entry[1] !== undefined), + ) const result = await execa(process.execPath, [dshBin, ...args], { input: '', timeout: 25_000, killSignal: 'SIGKILL', reject: false, - env, + env: childEnv, + extendEnv: false, + ...cwd === undefined ? {} : { cwd }, }) if (result.timedOut) { throw new Error(`dsh built bin did not exit within 25s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`) @@ -127,6 +135,44 @@ function startProfileLifecycle(fixture: ProfileLifecycleFixture) { }) } +function createEnvironmentProbeProfile(home: string, project: string): void { + const pluginFile = join(project, 'environment-probe.mjs') + writeFileSync(pluginFile, [ + "export const name = 'environment-probe'", + "export const inject = ['llm']", + 'export function apply(ctx) {', + ' void ctx.loader.await().then(async () => {', + " let text = ''", + ' for await (const chunk of ctx.llm.stream({', + " provider: 'deepseek-official',", + " model: 'deepseek-v4-flash',", + ' messages: [],', + ' maxTokens: 32,', + ' })) {', + " if (chunk.type === 'text-delta') text += chunk.text", + ' }', + ' process.stdout.write(`${text}\\n`)', + " process.kill(process.pid, 'SIGTERM')", + ' })', + '}', + '', + ].join('\n')) + const profileDir = join(home, 'profiles', 'environment-probe') + mkdirSync(profileDir, { recursive: true }) + writeFileSync(join(profileDir, 'package.json'), JSON.stringify({ + name: 'dsh-profile-environment-probe', + private: true, + dependencies: {}, + dsh: { profile: { bundles: ['@deepseek-ai/dsh-base'] } }, + }, undefined, 2)) + writeFileSync(join(profileDir, 'cordis.patch.yml'), [ + '- insert:', + ' - id: environment-probe', + ` name: ${pathToFileURL(pluginFile).href}`, + '', + ].join('\n')) +} + describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', () => { it('requires --profile and rejects removed commands', async () => { const bare = await runBuiltBin() @@ -156,6 +202,47 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', } }, 30_000) + it('uses the Harness-home environment and managed credential through the published entry', async () => { + const apiKey = 'built-home-layer-key' + const server = await startMockLlmServer({ + sequence: ['success'], + apiKey, + successText: 'home environment reached the mock', + }) + const home = mkdtempSync(join(tmpdir(), 'dsh-home-environment-')) + const project = mkdtempSync(join(tmpdir(), 'dsh-home-project-')) + writeFileSync(join(home, '.env'), `DEEPSEEK_BASE_URL=${server.baseURL}\n`) + writeFileSync(join(home, '.credentials.yaml'), `DEEPSEEK_API_KEY: ${apiKey}\n`, { mode: 0o600 }) + createEnvironmentProbeProfile(home, project) + try { + const result = await runBuiltBin( + ['--profile', 'environment-probe'], + { + DSH_HOME: home, + DSH_TELEMETRY_DISABLED: '1', + DEEPSEEK_API_KEY: undefined, + DEEPSEEK_BASE_URL: undefined, + }, + project, + ) + expect( + result.code, + `${result.stderr}\nstdout:\n${result.stdout}\nmock requests: ${String(server.requests.length)}`, + ).toBe(0) + expect(result.stdout).toBe('home environment reached the mock') + expect(result.stdout).not.toContain(apiKey) + expect(result.stderr).not.toContain(apiKey) + expect(server.requests).toHaveLength(1) + expect(server.requests[0]?.path).toBe('/chat/completions') + expect(server.requests[0]?.headers.authorization).toBe(`Bearer ${apiKey}`) + expect(JSON.stringify(server.requests[0]?.body)).not.toContain(apiKey) + } finally { + await server.close() + rmSync(home, { recursive: true, force: true }) + rmSync(project, { recursive: true, force: true }) + } + }, 30_000) + it('reports a patch-overlay boot failure without hanging', async () => { // The HMR main watcher's initial scan once refreshed the include // mid-initial-apply, deadlocking the failing apply's rollback against the diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c7c39a7522..4c4c100582 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -201,6 +201,9 @@ importers: '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ version: link:../../packages/host/webserver + '@deepseek-ai/dsh-llm-mock-server': + specifier: workspace:^ + version: link:../../packages/support/llm-mock-server '@deepseek-ai/dsh-loader-smoke': specifier: workspace:^ version: link:../../packages/support/loader-smoke From 356453d6cbe2b0c29d7d37ab799b5296dd7c4b9a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 7 Aug 2026 22:03:49 +0800 Subject: [PATCH 19/27] cleanup(config): remove literal credential compatibility residue Adapter schemas now carry only credential references, but the Models join, onboarding readiness, shipped overlays, SDK scaffolding, fixtures, and active decision prose still treated a redacted literal apiKey as a supported compatibility state. That residue made an unsupported field look contractual and pinned Schemastery silent-dropping as behavior. Delete those branches and examples, and let compositions and scaffolds use adapter-owned reference and environment resolution. Do not add a tombstone validator or change generic unknown-key behavior: literal adapter credentials have no migration contract to preserve. --- ...est-level-llm-config-credentials.i18n.yaml | 4 +-- ...29-request-level-llm-config-credentials.md | 2 +- ...request-level-llm-config-credentials.zh.md | 2 +- ...undaries-and-atomic-registration.i18n.yaml | 4 +-- ...tial-boundaries-and-atomic-registration.md | 4 +-- ...l-boundaries-and-atomic-registration.zh.md | 4 +-- ...4-configuration-source-ownership.i18n.yaml | 4 +-- ...26-08-04-configuration-source-ownership.md | 1 - ...08-04-configuration-source-ownership.zh.md | 1 - ...-08-06-api-key-format-validation.i18n.yaml | 4 +-- .../2026-08-06-api-key-format-validation.md | 14 +++------ ...2026-08-06-api-key-format-validation.zh.md | 14 +++------ ...06-provider-credential-lifecycle.i18n.yaml | 4 +-- ...026-08-06-provider-credential-lifecycle.md | 6 ++-- ...-08-06-provider-credential-lifecycle.zh.md | 6 ++-- ...seek-onboarding-credential-setup.i18n.yaml | 4 +-- ...30-deepseek-onboarding-credential-setup.md | 4 +-- ...deepseek-onboarding-credential-setup.zh.md | 4 +-- examples/acp-agent/tests/fs-search.cordis.yml | 2 -- examples/acp-agent/tests/pwsh.cordis.yml | 2 -- packages/bundle/web-app/cordis.patch.yml | 5 --- .../ui-models/src/client/ModelsSection.tsx | 14 ++++----- .../ui-models/src/client/ProviderEditor.tsx | 17 +++++----- packages/client/ui-models/src/client/store.ts | 17 ---------- .../ui-models/tests/components.spec.tsx | 31 ++++++------------- .../tests/onboarding-dialog.spec.tsx | 6 ++-- .../client/ui-models/tests/readiness.spec.ts | 8 ----- packages/client/ui-models/tests/store.spec.ts | 27 +--------------- .../examples/acp-demo/tests/load-path.e2e.ts | 2 -- .../llm/llm-deepseek/tests/adapter.spec.ts | 3 +- .../llm-deepseek/tests/dynamic-config.spec.ts | 17 ---------- packages/sdk/create-sdk/tests/create.spec.ts | 2 +- .../helper/src/features/builtin/provider.ts | 9 ++---- packages/sdk/helper/tests/documents.spec.ts | 12 +++---- packages/sdk/helper/tests/project.spec.ts | 3 +- packages/sdk/scripts/tests/scripts.spec.ts | 2 +- .../telemetry/tests/consent-resolver.spec.ts | 2 +- 37 files changed, 78 insertions(+), 189 deletions(-) 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 index 6a87258771..5524e0d54d 100644 --- 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 @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md -2026-07-29-request-level-llm-config-credentials.md: 5359865d1ca0c6620f4af1fa82c2f7e5413e79d6 -2026-07-29-request-level-llm-config-credentials.zh.md: 90f7c9447978f9621d9d940a56fd341714e69001 +2026-07-29-request-level-llm-config-credentials.md: 238400ea41f25a716729d1721c113645c2c8ba72 +2026-07-29-request-level-llm-config-credentials.zh.md: b0d04d4303bf0ccf5ebc74af8c4a3e493f861d63 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 index 5359865d1c..238400ea41 100644 --- 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 @@ -14,7 +14,7 @@ The [settings seam](2026-07-28-user-settings-seam.md) shipped without a producti **Per-request resolution, not fiber rebuilds.** The adapters take an options thunk (and a per-stream credential resolver) instead of frozen construction facts, resolving once per operation — the Pi pattern, with its tested semantics: two requests straddling a change see two configurations, one request resolves exactly once, and an in-flight stream keeps the facts it started with. This deletes the entire swap machinery a rebuild design needs (`DUPLICATE_ADAPTER` ordering, `NO_ADAPTER` windows, a deferred-activation state machine) and makes a missing key a *request-time* actionable failure (`MISSING_CREDENTIAL` naming every entry point) while the route stays registered and the catalog stays browsable. The one registration-captured fact — the retry policy the `ctx.llm` registry snapshots at `registerAdapter` (plus pi-ai's route *set*) — re-registers the same adapter instance in one synchronous section when it changes. -**Secrets are references, values live behind `ctx.credentials`.** Configuration (both planes) carries `apiKeyEnv: DEEPSEEK_API_KEY`; the three-package credential seam resolves it per operation. `credentials-local` layers the live process environment (read-only, wins — a launch-time override is operator intent and must be *visibly* read-only, so shadowed writes reject instead of appearing to succeed) over the provider-managed document (writable, wholesale snapshot replacement on reload so a deleted entry never lingers — the Claude Code additive-reapply lesson). That document was `$DSH_HOME/.env` in dotenv form; the [credentials document split](2026-08-04-credentials-yaml-and-user-environment-layer.md) later moved it to `$DSH_HOME/.credentials.yaml` and freed the old path to become the user's environment layer. Resolution order in the adapters is literal `apiKey` first (preserving the historical `config.apiKey ?? env` observable semantics), then the seam, then — only without a mounted seam — the raw environment variable. +**Secrets are references, values live behind `ctx.credentials`.** Configuration (both planes) carries `apiKeyEnv: DEEPSEEK_API_KEY`; the three-package credential seam resolves it per operation. `credentials-local` layers the live process environment (read-only, wins — a launch-time override is operator intent and must be *visibly* read-only, so shadowed writes reject instead of appearing to succeed) over the provider-managed document (writable, wholesale snapshot replacement on reload so a deleted entry never lingers — the Claude Code additive-reapply lesson). That document was `$DSH_HOME/.env` in dotenv form; the [credentials document split](2026-08-04-credentials-yaml-and-user-environment-layer.md) later moved it to `$DSH_HOME/.credentials.yaml` and freed the old path to become the user's environment layer. Adapters resolve the reference through the seam, or — only without a mounted seam — through the environment layers. **Per-plugin namespaces, schema ≡ `Config`.** Each adapter registers its own namespace (`llm-deepseek`, `llm-pi-ai`) with its plugin `Config` schema and its `cordis.yml` entry as the composition `base` — a settings section is the same YAML shape as the entry config, and `resolveAdapterOptions`/`resolveProfiles` stay the one explicit resolve step for both. A live snapshot failing a beyond-schema bound keeps the last good facts (the seam's last-good philosophy extended one level up); the entry config itself still fails load. pi-ai's `providers` became a dict keyed by route so base and user layers merge per provider and the route set is structural; the array shape fails loud with migration directions, and an empty dict is the valid dormant posture — a composition ships the adapter bare and every route stays a user-plane decision. 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 index 90f7c94479..b0d04d4303 100644 --- 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 @@ -14,7 +14,7 @@ Status: implemented **按请求解析,而非重建 fiber。**适配器改为接收一个 options thunk(外加按流调用的凭据解析器),不再持有冻结的构造期事实,每个操作解析一次——即 Pi 的模式,连同其经测试固定的语义:跨越一次变更的两个请求看到两份配置,一个请求恰好解析一次,进行中的流保持其起始事实。这删掉了重建式设计所需的整套切换机制(`DUPLICATE_ADAPTER` 顺序问题、`NO_ADAPTER` 窗口、延迟激活状态机),并把密钥缺失变成*请求时*可行动的失败(`MISSING_CREDENTIAL` 点名每个配置入口),同时路由保持注册、catalog 保持可浏览。唯一在注册期捕获的事实——`ctx.llm` 注册表在 `registerAdapter` 时快照的重试策略(外加 pi-ai 的路由*集合*)——在其变化时于一个同步区段内原地重新注册同一适配器实例。 -**机密是引用,值藏在 `ctx.credentials` 背后。**配置(两个面)携带 `apiKeyEnv: DEEPSEEK_API_KEY`;三包凭据 seam 按操作解析它。`credentials-local` 把活跃进程环境(只读、优先——启动时覆盖是操作者意图,必须*可见地*只读,因此被遮蔽的写入直接拒绝而不是表面成功)叠加在 provider 管理的文档之上(可写、重载时整体替换快照使删除的条目绝不滞留——来自 Claude Code 增量重放(additive reapply)的教训)。该文档当时是 dotenv 形式的 `$DSH_HOME/.env`;[凭据文档拆分](2026-08-04-credentials-yaml-and-user-environment-layer.md)后来把它移到 `$DSH_HOME/.credentials.yaml`,并让旧路径转为用户的环境层。适配器内的解析顺序为:字面 `apiKey` 优先(保留历史 `config.apiKey ?? env` 的可观察语义),然后是 seam,最后——仅在未挂载 seam 时——原始环境变量。 +**机密是引用,值藏在 `ctx.credentials` 背后。**配置(两个面)携带 `apiKeyEnv: DEEPSEEK_API_KEY`;三包凭据 seam 按操作解析它。`credentials-local` 把活跃进程环境(只读、优先——启动时覆盖是操作者意图,必须*可见地*只读,因此被遮蔽的写入直接拒绝而不是表面成功)叠加在 provider 管理的文档之上(可写、重载时整体替换快照使删除的条目绝不滞留——来自 Claude Code 增量重放(additive reapply)的教训)。该文档当时是 dotenv 形式的 `$DSH_HOME/.env`;[凭据文档拆分](2026-08-04-credentials-yaml-and-user-environment-layer.md)后来把它移到 `$DSH_HOME/.credentials.yaml`,并让旧路径转为用户的环境层。适配器通过 seam 解析该引用;仅在未挂载 seam 时,才通过各环境层解析。 **按插件划分 namespace,schema ≡ `Config`。**每个适配器注册自己的 namespace(`llm-deepseek`、`llm-pi-ai`),schema 用其插件 `Config` schema,组合 `base` 用其 `cordis.yml` 条目——settings 分节与 entry 配置是同一种 YAML 形状,`resolveAdapterOptions`/`resolveProfiles` 对两者仍是唯一的显式 resolve 步骤。存活快照若违反 schema 之外的约束,则保留最后可用事实(seam 的最后可用值哲学向上延伸一层);entry 配置本身仍会加载失败。pi-ai 的 `providers` 改为以路由为键的字典,base 层与用户层因此按提供方合并,路由集合也由结构直接表达;数组形状响亮失败并给出迁移指引,而空字典是合法的休眠姿态——组合可以裸挂该适配器,把每一条路由都留给用户面决定。 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 index db8945f8a2..4becd41658 100644 --- 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 @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md -2026-07-30-credential-boundaries-and-atomic-registration.md: a093a78d7e3dafe218eb8f1013f226de0d6d9a0b -2026-07-30-credential-boundaries-and-atomic-registration.zh.md: 6dcb5fb6bba336ddb2d8de659ef32670ec07129e +2026-07-30-credential-boundaries-and-atomic-registration.md: 94b32c3cfaa3e1c5059573881a2f393d29aed3ac +2026-07-30-credential-boundaries-and-atomic-registration.zh.md: 2506ce391c9125323faf107e3c04c52785e0cc98 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 index a093a78d7e..94b32c3cfa 100644 --- 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 @@ -10,7 +10,7 @@ English | [中文](2026-07-30-credential-boundaries-and-atomic-registration.zh.m Review found the credential path leaking across boundaries it had drawn. The shipped surfaces hoisted `$DSH_HOME/.env` into `process.env` before cordis booted, so on the next run `credentials-local` classified every key it had stored itself as a read-only ambient launch override: `describe()` reported `source: 'env'` with `writable: false`, `set`/`unset` rejected as shadowed, and a key stored from the web page or TUI became unrotatable and undeletable while the adapter kept using the value captured at launch. The store's own write path repeated the settings-local defects that same review round fixed (two independent chains, whole-file render from a stale cache), plus editor bugs of its own: a physical line inside another key's quoted multi-line value read as an assignment, CRLF endings degraded to LF, a multi-line entry reported `writable: true` while `set` always threw, and `credentials/updated` was emitted bare after the commit, so one broken observer made a durable write look failed. On the read side, the file's `0600` mode stops other OS users but not the model, whose bash and filesystem tools run as the same user. -Two request-path defects sat beside them. DeepSeek's per-request resolution kept connection facts in a last-good snapshot but re-read the literal `apiKey` from the raw configuration, so a settings generation the resolver rejected could still put its key on the previous generation's endpoint. pi-ai handed the SDK `undefined` when a configured `apiKeyEnv` resolved to nothing, letting pi-ai's own environment discovery authenticate with an unrelated provider key — another tenant, silently billed. And its route swap disposed the old registration before creating the new one: a route another adapter owned dropped every existing route, after which the facts cache could equal the registry's, so restoring the working configuration never re-applied. +Two request-path defects sat beside them. DeepSeek resolved connection and credential facts independently, so a settings generation the resolver rejected could still pair its credential choice with the previous generation's endpoint. pi-ai handed the SDK `undefined` when a configured `apiKeyEnv` resolved to nothing, letting pi-ai's own environment discovery authenticate with an unrelated provider key — another tenant, silently billed. And its route swap disposed the old registration before creating the new one: a route another adapter owned dropped every existing route, after which the facts cache could equal the registry's, so restoring the working configuration never re-applied. ## Decision @@ -18,7 +18,7 @@ Two request-path defects sat beside them. DeepSeek's per-request resolution kept **The stored credential has no boundary against the model, and the READMEs say so.** `0600` under a `0700` directory stops other OS users; the model's bash and filesystem tools run as that same user, and the shipped default confines nothing. What the harness does hold to is narrower and stated as exactly that: no surface hoists the document into `process.env`, and the model is never handed a resolved path to it, so reaching the value takes a deliberate read of a path it was not given. An OS-keychain provider — a store the model's processes cannot read at all — is recorded as the real answer rather than implied by a partial one. -**One request, one generation.** DeepSeek's resolved snapshot carries the credential facts (literal key and reference) beside the endpoint, and `resolveApiKey` receives that snapshot instead of re-reading configuration. A rejected generation now contributes nothing at all. pi-ai defers to provider-native discovery only for a profile naming no credential; a configured reference that misses fails with `MISSING_CREDENTIAL` naming the route and the reference. The boot-time credential probe is deleted: it could run before the credentials service mounted and reported every failure as a missing key, while the first request already gives the accurate error. +**One request, one generation.** DeepSeek's resolved snapshot carries the credential reference beside the endpoint, and `resolveApiKey` receives that snapshot instead of re-reading configuration. A rejected generation now contributes nothing at all. pi-ai defers to provider-native discovery only for a profile naming no credential; a configured reference that misses fails with `MISSING_CREDENTIAL` naming the route and the reference. The boot-time credential probe is deleted: it could run before the credentials service mounted and reported every failure as a missing key, while the first request already gives the accurate error. **Route replacement is a registry operation, not a caller sequence.** `registerAdapter` returns a handle carrying `replace(providers)`: the candidate set is validated in full first (conflicts, names, provider metadata), then swapped in one synchronous section. A refused replacement leaves the previous routes registered and serving, and the caller's facts cache only advances after the registry actually holds the new set, so reverting to a working configuration re-applies. pi-ai's registration facts are sorted by provider, so a settings document that merely reorders its keys is no longer a route change. 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 index 6dcb5fb6bb..2506ce391c 100644 --- 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 @@ -14,7 +14,7 @@ Status: implemented 在读取一侧,文件的 `0600` 权限挡得住其他 OS 用户,却挡不住模型:它的 bash 与文件系统工具就以同一个用户身份运行。 -与之并排的还有两个请求路径缺陷。DeepSeek 的按请求解析把连接事实保存在最后可用快照里,却仍从原始配置重新读取字面 `apiKey`,于是被 resolver 拒绝的那一代设置,照样能把自己的密钥送到上一代的端点上。配置了 `apiKeyEnv` 却解析不到值时,pi-ai 会把 `undefined` 交给 SDK,让 pi-ai 自己的环境发现拿一个毫不相干的提供方密钥完成鉴权——那是另一个租户,账单还悄悄记在它头上。而且它的路由替换是先释放旧注册、再创建新注册:只要有一条路由已被别的适配器占有,现有路由就会被全部丢掉,此后事实缓存可能与注册表中的事实相等,于是把配置改回可用状态也不会重新生效。 +与之并排的还有两个请求路径缺陷。DeepSeek 分别解析连接事实与凭据事实,因此被 resolver 拒绝的那一代设置仍可能把自己的凭据选择与上一代的端点配在一起。配置了 `apiKeyEnv` 却解析不到值时,pi-ai 会把 `undefined` 交给 SDK,让 pi-ai 自己的环境发现拿一个毫不相干的提供方密钥完成鉴权——那是另一个租户,账单还悄悄记在它头上。而且它的路由替换是先释放旧注册、再创建新注册:只要有一条路由已被别的适配器占有,现有路由就会被全部丢掉,此后事实缓存可能与注册表中的事实相等,于是把配置改回可用状态也不会重新生效。 ## 决策 @@ -22,7 +22,7 @@ Status: implemented **存下的凭据对模型没有边界,而 README 就是这么写的。**`0700` 目录下的 `0600` 挡得住其他 OS 用户;模型的 bash 与文件系统工具正是以同一用户身份运行,而已交付的默认值不约束任何东西。harness 真正守住的更窄,也就照这个宽度写下来:没有任何一个面会把该文档提升进 `process.env`,模型也从不会拿到它的解析后路径,因此要拿到这个值,需要刻意去读一条并未交给它的路径。OS 钥匙串(keychain)提供方——一个模型的进程根本读不到的存储——被记录为真正的答案,而不是靠一个残缺的方案去暗示它。 -**一次请求,一代设置。**DeepSeek 解析出的快照在端点旁一并携带凭据事实(字面密钥与引用),`resolveApiKey` 接收这份快照,而不再重新读取配置。被拒绝的那一代如今完全不再贡献任何东西。只有当一个 profile 完全没有点名凭据时,pi-ai 才交给提供方原生的发现流程;配置了引用却解析不到,就以 `MISSING_CREDENTIAL` 失败,并点名该路由与该引用。启动时的凭据探测被删除:它可能在凭据服务挂载之前就运行,并把每一种失败都报成密钥缺失,而第一次请求本就会给出准确的错误。 +**一次请求,一代设置。**DeepSeek 解析出的快照在端点旁一并携带凭据引用,`resolveApiKey` 接收这份快照,而不再重新读取配置。被拒绝的那一代如今完全不再贡献任何东西。只有当一个 profile 完全没有点名凭据时,pi-ai 才交给提供方原生的发现流程;配置了引用却解析不到,就以 `MISSING_CREDENTIAL` 失败,并点名该路由与该引用。启动时的凭据探测被删除:它可能在凭据服务挂载之前就运行,并把每一种失败都报成密钥缺失,而第一次请求本就会给出准确的错误。 **路由替换是注册表的操作,不是调用方的一串步骤。**`registerAdapter` 返回一个携带 `replace(providers)` 的句柄:候选集合先被完整校验(冲突、名称、提供方元数据),再在一个同步区段内完成替换。被拒绝的替换会让先前的路由保持注册并继续服务,而调用方的事实缓存只有在注册表确实持有新集合之后才会推进,因此改回可用配置时会重新生效。pi-ai 的注册事实按提供方排序,因此仅仅调换键顺序的设置文档不再算作路由变更。 diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml index 32f4e05648..2d966fa8ea 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.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 .agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md -2026-08-04-configuration-source-ownership.md: 2603736e35fbf838609fd2ca133785cfe5534e27 -2026-08-04-configuration-source-ownership.zh.md: 98c9291503201db81b5b4797dcc04823e0a27db7 +2026-08-04-configuration-source-ownership.md: 0b11df50c8f00875a92b722e9f225dd27ed218b5 +2026-08-04-configuration-source-ownership.zh.md: 648cea0167bef564195597f7b2791b5211d40267 diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md index 2603736e35..0b11df50c8 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md @@ -56,7 +56,6 @@ The line is that these take effect with no user action, before any turn, outside - A `.env` holding `DSH_*`, `PATH`, or a proxy variable fails the launch instead of being applied. Developers keeping switches in a repository `.env` move them to their shell — a deliberate, loud break. - Composition is no longer overridable by a stale shell endpoint. It is still overridable by a user's stored `settings.yaml`, which is the settings seam's layering and not something this note changes; the product CLI offers no flag above it, so a deployment that must win against stored settings owns its own bin or loader tree. - Not solved: the layers are still materialized into `process.env`, so ordinary project variables continue to reach child processes under the subprocess scrub. Bootstrap variables cannot come from a file at all; the environment package records the remaining subprocess reach as a limitation. -- The LLM adapters no longer accept a literal `apiKey`: configuration carries the reference and nothing else, so a settings document cannot become a second credential store. No adapter namespace is strict, so writing one is dropped rather than rejected. The web-search providers still declare a `role('secret')` literal key; they register no settings namespace, so nothing can shadow a stored credential through them, but the claim is about the adapters rather than the repository as a whole. - Exa and Perplexity still capture their key at load time rather than through the credential seam. They no longer read raw `process.env` — they resolve through the trusted layers — but converting them to per-request seam resolution is separate work. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md index 98c9291503..648cea0167 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md @@ -58,7 +58,6 @@ inherited process environment (read-only, wins) - 含 `DSH_*`、`PATH` 或 proxy 变量的 `.env` 会导致启动失败而不是被应用。把开关放在仓库 `.env` 里的开发者需要改放到 shell——这是一次刻意且响亮的破坏。 - composition 不再会被陈旧的 shell endpoint 覆盖。但它仍然会被用户已存的 `settings.yaml` 覆盖,这是 settings seam 的分层方式,本 Note 不改变它;产品 CLI 没有高于它的标志,因此需要压过已存 settings 的部署方要自带 bin 或 loader 配置树。 - 未解决的:各层仍然会被物化进 `process.env`,因此普通项目变量继续按子进程清洗规则抵达子进程。bootstrap 变量完全不能来自文件;其余变量抵达子进程的限制记录在环境包中。 -- LLM 适配器不再接受字面 `apiKey`:配置只携带引用,因此 settings 文档无法成为第二个凭据存储。由于没有任何适配器 namespace 是 strict 的,写入该键会被 schema 丢弃而不是报错。web-search 提供方仍声明 `role('secret')` 的字面密钥字段;它们不注册 settings namespace,因此无法借此遮蔽已存凭据,但这条声明的范围是适配器,而不是整个仓库。 - Exa 与 Perplexity 仍在加载时捕获密钥,而不是经凭据 seam。它们不再读裸 `process.env`——改为经受信层解析——但把它们改造成按请求经 seam 解析是另一件事。 ## Alternatives considered diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml index 123058ac7e..e1c3ac3ef8 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.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 .agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md -2026-08-06-api-key-format-validation.md: d1f6d31d362b76392514704be780f553b45d36ad -2026-08-06-api-key-format-validation.zh.md: 75b3fa247bdf449964a874e909e6e3bc9e0694fa +2026-08-06-api-key-format-validation.md: e9ca76ede06080f2b868f6436998d163e642adbc +2026-08-06-api-key-format-validation.zh.md: 5666a884d4c9478291072375681d8d3526b2632a diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md index d1f6d31d36..e9ca76ede0 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md @@ -12,7 +12,7 @@ Pasting a key containing an emoji, CJK text, or a full-width punctuation mark in `llm-pi-ai` was worse on the same input. Its discovery probe builds the same header with a bare `fetch` in [discovery.ts](../../../../packages/llm/llm-pi-ai/src/discovery.ts) and wrapped every failure as `could not reach `, so a local key fault was reported as an unreachable network. The probe is reachable from the unsaved draft: `ProviderEditor` puts the typed `keyDraft` into its probe request, so the model-listing button sent an illegal key before anything was stored. -Whitespace passed every check. `ProviderEditor` tested `keyDraft.length` and `resolveAdapterOptions` tested `config.apiKey.length`, so a key of three spaces stored and then authenticated as `Bearer` plus blanks. `llm-pi-ai` rejected an empty literal `apiKey` in `resolveProfiles`, but applied no check whatsoever to a credential- or environment-sourced key — the path the Models page writes, and therefore the path users actually take. +Whitespace passed every check. `ProviderEditor` tested `keyDraft.length`, so a key of three spaces was stored and then authenticated as `Bearer` plus blanks. Neither adapter checked a credential- or environment-sourced key — the path the Models page writes, and therefore the path users actually take. Sources: deepseek-harness#1594 and #1595; dsh-external#247, #249, #266, and #210. @@ -32,13 +32,13 @@ The shape rule is a guess about how people paste, so it runs **only in the brows ### Absence is a configuration state, not a missing key -"No API key" means three different things here, and only one of them is an error. The rule applies to a value that was *provided*; deciding whether one was provided at all stays with each caller. +The rule applies to a value that was *provided*; deciding whether one was provided at all stays with each caller. -**Omitted.** A profile naming neither `apiKey` nor `apiKeyEnv` is authenticated by something other than a harness-held key. `routeAuth` in [provider.ts](../../../../packages/llm/llm-pi-ai/src/provider.ts) keeps the installed catalog provider's own auth precisely so provider-native ambient discovery survives, and `openai-codex` — shipped in that catalog — authenticates through OAuth and refuses an explicit key outright. `namesCredential` carries this distinction. In `llm-deepseek`, an absent `apiKey` likewise falls through to `apiKeyEnv`. Omission is never validated. +**No named credential.** A pi-ai profile omitting `apiKeyEnv` may authenticate outside the harness-held credential path. `routeAuth` in [provider.ts](../../../../packages/llm/llm-pi-ai/src/provider.ts) keeps the installed catalog provider's own auth precisely so provider-native ambient discovery survives, and `openai-codex` — shipped in that catalog — authenticates through OAuth. `namesCredential` carries this distinction; omission is not a value to validate. **A blank field in the web UI.** The key input opens empty even for a provider whose key is already stored — the `keyStored` copy reads "Configured — enter a new value to replace" — so blank means *keep what is stored*. `ProviderEditor` skips `credentials.set` entirely when the draft is empty, and that stays a no-op: a blank field never blocks submit, or editing a base URL would demand re-entering the key. -**Provided, but empty or whitespace-only.** What this means depends on what absence selects for that surface, and the two adapters differ for a reason. In `llm-pi-ai` it is an error, because absence there switches authentication mode — to the installed provider's ambient discovery or OAuth — so a blank key leaves genuine ambiguity about which was meant; its wording names the legitimate alternative rather than just refusing (*has an empty apiKey; omit it to use ambient authentication*). In `llm-deepseek` absence merely selects a different *source* for the same key, `apiKeyEnv`, so a blank literal resolves through that fallback exactly as an omitted one does. In the browser it is always a failure, on both cards: the field is where a person just typed, and silently discarding what they typed is never the right answer. +**A resolved value that is whitespace-only.** This is invalid at both adapters because it cannot authenticate a request. In the browser it is also a field-level failure: the field is where a person just typed, and silently discarding what they typed is never the right answer. `normalizeApiKey` therefore takes `string`, never `string | undefined`. @@ -55,9 +55,7 @@ The client cannot import any of this: client packages reference only client pack | Surface | Behavior | |---|---| | `dsh-llm` | Owns `normalizeApiKey`, `assertUsableApiKey`, and `INVALID_CREDENTIAL_CODE`, which is deliberately outside `DEFAULT_RETRYABLE_CODES`. | -| `llm-deepseek` `resolveAdapterOptions` | Refuses a literal `apiKey` no header can carry, beside the other beyond-schema bounds; uses the trimmed value. An absent or blank one falls through to `apiKeyEnv`. | | `llm-deepseek` `resolveApiKey` | Normalizes what the credentials seam or environment returns, rejecting with `INVALID_CREDENTIAL` naming the Models page and never echoing the key. | -| `llm-pi-ai` `resolveProfiles` | Applies the shared rule, keeping its "omit it to use ambient authentication" wording, and writes the trimmed value into the resolved profile. | | `llm-pi-ai` `resolveApiKey` | Normalizes the credential and environment paths. A profile naming no credential still returns `undefined`, so ambient and OAuth routes are unaffected. | | `llm-pi-ai` `discoverModels` | Normalizes before building the header, so an illegal key is a credential fault rather than an unreachable endpoint. A probe carrying no key stays unauthenticated. | | `ui-models` | Mirrors the charset rule, adds the shape heuristic, trims `keyDraft` before probe and `credentials.set`, and fixes the `stringAt` emptiness test. A blank field remains a no-op that submits; a field holding only whitespace is a field-level failure. Submit **and the endpoint interrogation** are both gated, so a refused key never spends a round trip to be told what the field already says, and the failure renders on the field, matching the existing `modelFailure` pattern. | @@ -68,8 +66,6 @@ The client cannot import any of this: client packages reference only client pack ## Alternatives considered -**A `.pattern()` on the `apiKey` schema field.** Vendored schemastery supports it, and the pattern would serialize to the browser with the rest of the namespace schema — one rule, delivered rather than mirrored. It lost because a pattern cannot trim first: `cordis.yml` would then reject a padded key while `.env` tolerated one, and the resolver would disagree with the schema about the same string. Validating in `resolveAdapterOptions` keeps every surface trim-then-validate, and that function is already where this package re-judges bounds the schema cannot express. - **A validation module shared by client and host.** Rejected by the source-plane layout: client packages reference only client packages plus `vendor/cordis` and `support/invariants`, and widening that to reach a host package would collide the two `Context` merges the split exists to keep apart. Mirroring a one-line predicate with a test on each side is the established shape here. **A per-adapter thrower in each of `llm-deepseek` and `llm-pi-ai`.** The first plan gave each adapter its own, differing only by the package prefix in the message, with a duplication-gate exemption to excuse the pair. Rejected before implementation: `LlmError` is declared in the seam, so the seam can own the diagnosis outright, and an exemption there would have hidden exactly the duplication it was covering for. @@ -100,7 +96,7 @@ The costliest way to get this wrong would have been to treat absence as invalidi `packages/llm/llm/tests/api-key.spec.ts` drives `normalizeApiKey` and `assertUsableApiKey` over the whole input table — empty, whitespace-only, padded, interior-space, C0 control, emoji, CJK, full-width, latin-1, and the printable-ASCII boundary — and pins that a refusal carries `INVALID_CREDENTIAL` and no part of the key. -`packages/llm/llm-deepseek/tests/` covers the literal-config path in `adapter.spec.ts` and the stored-credential path end to end in `dynamic-config.spec.ts`, through the real credentials seam rather than a stub. `packages/llm/llm-pi-ai/tests/` covers `resolveProfiles` — including that the trimmed value reaches the resolved profile, which the `...rest` spread would otherwise discard — and the discovery probe, including that a probe with no key sends no `authorization` header. +`packages/llm/llm-deepseek/tests/` covers the stored-credential path end to end in `dynamic-config.spec.ts`, through the real credentials seam rather than a stub. `packages/llm/llm-pi-ai/tests/` covers the discovery probe, including that a probe with no key sends no `authorization` header. `packages/client/ui-models/tests/` pins `apiKeyFailure` over the same table plus the paste-shape cases, and drives both cards: a blank field submits without writing a credential, a whitespace-only field fails on the field, an illegal or wrapped key blocks submit and the interrogation alike, a padded key is trimmed before `credentials.set` and before an interrogation, and a hand-declared route can be created with no key at all. diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md index 75b3fa247b..5666a884d4 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md @@ -12,7 +12,7 @@ Status: implemented 同样的输入在 `llm-pi-ai` 上更糟。它的探测路径在 [discovery.ts](../../../../packages/llm/llm-pi-ai/src/discovery.ts) 里用裸 `fetch` 构造同一个 header,并把一切失败包装成 `could not reach `,于是一个本地的 Key 故障被报成网络不可达。这条探测在保存之前就够得着:`ProviderEditor` 把用户输入的 `keyDraft` 直接放进探测请求,所以「获取模型列表」按钮会在任何东西落盘之前就把非法 Key 发出去。 -空白字符能通过每一道检查。`ProviderEditor` 判的是 `keyDraft.length`,`resolveAdapterOptions` 判的是 `config.apiKey.length`,于是三个空格构成的 Key 会被存下,随后以 `Bearer` 加若干空格去认证。`llm-pi-ai` 在 `resolveProfiles` 中拒绝空的字面量 `apiKey`,却对来自凭据或环境的 Key 完全不做检查——而那正是模型设置页写入的路径,也就是用户真正走的路径。 +空白字符能通过每一道检查。`ProviderEditor` 判的是 `keyDraft.length`,于是三个空格构成的 Key 会被存下,随后以 `Bearer` 加若干空格去认证。两个适配器都不检查来自凭据或环境的 Key——而那正是 Models 页写入的路径,也就是用户真正走的路径。 来源:deepseek-harness#1594 与 #1595;dsh-external#247、#249、#266、#210。 @@ -32,13 +32,13 @@ Status: implemented ### 「没有 Key」是一种配置状态,不是缺失 -在这里,「没有 API Key」意味着三件完全不同的事,其中只有一件是错误。规则作用于**已提供**的值;至于究竟有没有提供,由各个调用方自行判断。 +规则作用于*已提供*的值;至于究竟有没有提供,由各个调用方自行判断。 -**未指定。** 既不写 `apiKey` 也不写 `apiKeyEnv` 的 profile,是由 harness 所持有的 Key 之外的东西来鉴权的。[provider.ts](../../../../packages/llm/llm-pi-ai/src/provider.ts) 中的 `routeAuth` 保留内置 catalog provider 自身的鉴权,正是为了让 provider 原生的 ambient 发现得以存活;而该 catalog 附带的 `openai-codex` 通过 OAuth 鉴权,并会直接拒绝一个显式的 Key。`namesCredential` 承载着这一区分。在 `llm-deepseek` 中,缺省的 `apiKey` 同样会回落到 `apiKeyEnv`。未指定的情形永不参与校验。 +**未点名凭据。** 省略 `apiKeyEnv` 的 pi-ai profile 可以在 harness 持有的凭据路径之外鉴权。[provider.ts](../../../../packages/llm/llm-pi-ai/src/provider.ts) 中的 `routeAuth` 保留内置 catalog provider 自身的鉴权,正是为了让 provider 原生的 ambient 发现继续工作;而该 catalog 附带的 `openai-codex` 通过 OAuth 鉴权。`namesCredential` 承载这一区分;省略不是需要校验的值。 **Web UI 中留空的输入框。** 即便某个 provider 的 Key 已经存好,该输入框也是空着打开的——`keyStored` 的文案写的是「已配置——输入新值以替换」——所以留空意味着*保持已存储的值*。`ProviderEditor` 在草稿为空时完全跳过 `credentials.set`,这一点保持不变:留空绝不拦截提交,否则改一个 base URL 都得重新输一遍 Key。 -**已提供,但为空或纯空白。** 它意味着什么,取决于「缺失」在该界面上选中了什么,而两个适配器的差异是有依据的。在 `llm-pi-ai` 中它是错误,因为那里的缺失切换的是**鉴权方式**——转向内置 provider 的 ambient 发现或 OAuth——因此一个空 Key 究竟想选哪一种是真有歧义;它的措辞指明了合法替代路径而非单纯拒绝(*has an empty apiKey; omit it to use ambient authentication*)。在 `llm-deepseek` 中,缺失只是为同一把 Key 选择了另一个**来源** `apiKeyEnv`,因此空白字面量会像缺省一样经该回落解析。在浏览器中它始终是失败,两张卡片皆然:字段是人刚刚敲过字的地方,静默丢弃他敲进去的内容永远不是正确答案。 +**解析得到的值只含空白。** 两个适配器都将其视为非法,因为它无法为请求鉴权。在浏览器中,这同样是字段级失败:字段是人刚刚敲过字的地方,静默丢弃他敲进去的内容永远不是正确答案。 因此 `normalizeApiKey` 接受 `string`,而绝非 `string | undefined`。 @@ -55,9 +55,7 @@ Status: implemented | 界面 | 行为 | |---|---| | `dsh-llm` | 拥有 `normalizeApiKey`、`assertUsableApiKey` 与 `INVALID_CREDENTIAL_CODE`,后者刻意不进 `DEFAULT_RETRYABLE_CODES`。 | -| `llm-deepseek` `resolveAdapterOptions` | 拒绝标头无法承载的字面量 `apiKey`,与其他超出 schema 的边界检查并排;使用 trim 后的值。缺省或空白的 `apiKey` 回落到 `apiKeyEnv`。 | | `llm-deepseek` `resolveApiKey` | 归一化凭据 seam 或环境返回的值,以 `INVALID_CREDENTIAL` 拒绝,消息指明模型设置页,绝不回显 Key。 | -| `llm-pi-ai` `resolveProfiles` | 施加这条共享规则,保留其「omit it to use ambient authentication」的措辞,并把 trim 后的值写进解析后的 profile。 | | `llm-pi-ai` `resolveApiKey` | 归一化凭据与环境路径。不指定任何凭据的 profile 仍返回 `undefined`,ambient 与 OAuth 路由不受影响。 | | `llm-pi-ai` `discoverModels` | 在构造 header 之前归一化,使非法 Key 成为凭据故障而非端点不可达。不带 Key 的探测保持未鉴权。 | | `ui-models` | 镜像字符集规则,加入形状启发式,在探测与 `credentials.set` 之前 trim `keyDraft`,并修正 `stringAt` 的空值判断。留空的输入框仍是可以提交的空操作;只含空白的输入框则是字段级失败。提交**与端点探测**同时受拦截,因此被拒绝的密钥不会白花一次往返去换取字段上已经写明的答案;失败呈现在字段上,与既有的 `modelFailure` 模式一致。 | @@ -68,8 +66,6 @@ Status: implemented ## Alternatives considered -**在 `apiKey` schema 字段上加 `.pattern()`。** vendor 中的 schemastery 支持它,且该 pattern 会随命名空间 schema 一同序列化到浏览器——一条规则,投递而非镜像。它落败于 pattern 无法先行 trim:那样 `cordis.yml` 会拒绝带首尾空白的 Key 而 `.env` 却容忍,resolver 与 schema 会对同一个字符串给出分歧。在 `resolveAdapterOptions` 中校验可以让每一层都是 trim-then-validate,而该函数本就是本包重新裁定 schema 无法表达的边界之处。 - **由 client 与 host 共享一个校验模块。** 被 source plane 布局否决:client 包只 reference client 包外加 `vendor/cordis` 与 `support/invariants`,把它放宽到够得着 host 包会撞上这一分割本就要隔开的两份 `Context` 合并。在两侧各镜像一行断言并各配一份测试,是此处的既定形态。 **在 `llm-deepseek` 与 `llm-pi-ai` 中各留一个抛错 helper。** 最初的计划正是各留一份,差别仅在消息中的包名前缀,并配一个重复检测豁免来放行这一对。在实现之前即被否决:`LlmError` 声明在 seam 中,因此 seam 完全可以自己拥有这句诊断,而那里的一个豁免恰恰会掩盖它本要遮掩的重复。 @@ -100,7 +96,7 @@ Status: implemented `packages/llm/llm/tests/api-key.spec.ts` 以整张输入表驱动 `normalizeApiKey` 与 `assertUsableApiKey`——空值、纯空白、带首尾空白、含中间空格、C0 控制字符、emoji、中文、全角、latin-1,以及可打印 ASCII 的边界字符——并钉住一次拒绝携带 `INVALID_CREDENTIAL` 且不含 Key 的任何部分。 -`packages/llm/llm-deepseek/tests/` 在 `adapter.spec.ts` 中覆盖字面量配置路径,在 `dynamic-config.spec.ts` 中经真实凭据 seam(而非 stub)端到端覆盖已存储凭据路径。`packages/llm/llm-pi-ai/tests/` 覆盖 `resolveProfiles`——包括 trim 后的值确实到达解析后的 profile,否则会被 `...rest` 展开丢弃——以及探测路径,包括不带 Key 的探测不会发出 `authorization` 标头。 +`packages/llm/llm-deepseek/tests/` 在 `dynamic-config.spec.ts` 中经真实凭据 seam(而非 stub)端到端覆盖已存储凭据路径。`packages/llm/llm-pi-ai/tests/` 覆盖探测路径,包括不带 Key 的探测不会发出 `authorization` 标头。 `packages/client/ui-models/tests/` 以同一张表加上形状用例钉住 `apiKeyFailure`,并驱动两张卡片:留空的输入框可提交且不写入凭据、只含空白的输入框在字段上失败、非法或被包裹的 Key 同时拦截提交与探测、带首尾空白的 Key 在 `credentials.set` 与探测之前被 trim,以及手工声明的路由可以完全不带 Key 创建。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.i18n.yaml index 9f16a183b9..de0b20d867 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.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 .agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.md -2026-08-06-provider-credential-lifecycle.md: ce45207e7ac7224f44e34945e36ba85db0971f09 -2026-08-06-provider-credential-lifecycle.zh.md: c476417517b8ed72036344a13720a8ba378775e6 +2026-08-06-provider-credential-lifecycle.md: c28788921e8f1b233b44e19b29ad4d4acaa25022 +2026-08-06-provider-credential-lifecycle.zh.md: 2ea3b21fb6ceb4fa38a0cad0daf47c3b6a98a664 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.md b/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.md index ce45207e7a..c28788921e 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.md @@ -12,7 +12,7 @@ The Models editor spans independent settings and credential RPC domains. It prev Provider save remains a two-stage settings-then-credentials operation over the existing wire domains, but the card treats the successful settings response as a commit checkpoint. It replaces its comparison subtree and expected revision with the returned redacted descriptor before attempting `credentials.set`; if that second stage fails, the draft key and card stay visible, and retry produces no settings ops and repeats only the credential write. Genuine concurrent changes before the first settings commit still fail with `settings-conflict`. Typed keys are trimmed at the UI and direct DeepSeek resolver boundaries, and pi-ai records a derived reference only when the normalized key is non-empty; saving a blank key materializes an empty, reference-free profile for provider-native discovery. -Deletion removes a credential only when the joined row identifies the exact `_API_KEY` reference derived by this page and reports it configured and writable. It unsets that credential before the user-layer profile so a settings-stage failure leaves the row and its frozen target visible for retry; both unsets are idempotent. Custom references, environment credentials, missing credentials, and targets the join cannot identify are retained. The row's accessible Edit/Delete names and the destructive dialog title, description, and final action all use the same stable `Display Name (route-id)` identity, collapsing to the route id when both strings match. The dialog states whether the stored key will be removed and owns operation failures instead of replacing the whole page with a load-error banner. Rows expose API-key state only from the value-free join: a confirmed literal or referenced credential is a green solid dot, a confirmed missing named reference is a red solid dot, and reference-free provider-native authentication or unavailable credential enrichment has no dot. Each dot has accessible copy and a tooltip, while successful Apply uses the same provider identity in a local status message and never echoes secret material. +Deletion removes a credential only when the joined row identifies the exact `_API_KEY` reference derived by this page and reports it configured and writable. It unsets that credential before the user-layer profile so a settings-stage failure leaves the row and its frozen target visible for retry; both unsets are idempotent. Custom references, environment credentials, missing credentials, and targets the join cannot identify are retained. The row's accessible Edit/Delete names and the destructive dialog title, description, and final action all use the same stable `Display Name (route-id)` identity, collapsing to the route id when both strings match. The dialog states whether the stored key will be removed and owns operation failures instead of replacing the whole page with a load-error banner. Rows expose API-key state only from the value-free join: a confirmed referenced credential is a green solid dot, a confirmed missing named reference is a red solid dot, and reference-free provider-native authentication or unavailable credential enrichment has no dot. Each dot has accessible copy and a tooltip, while successful Apply uses the same provider identity in a local status message and never echoes secret material. ## Alternatives considered @@ -20,8 +20,8 @@ Deletion removes a credential only when the joined row identifies the exact `_API_KEY` 引用,并将其报告为已配置且可写时,删除操作才会清除该凭据。它会先取消设置该凭据,再取消设置用户层 profile;如果 settings 阶段失败,该行及其已冻结的目标仍可见,便于重试。两项 unset 都具备幂等性。自定义引用、环境凭据、缺失的凭据,以及联接无法识别目标的凭据均会保留。行的无障碍 Edit/Delete 名称以及破坏性对话框的标题、说明和最终操作都使用同一个稳定的 `Display Name (route-id)` 标识;当两个字符串相同时,标识会简化为路由 id。对话框会说明是否一并删除已存密钥,并在自身内显示操作失败,而不是用加载错误横幅替换整个页面。行只根据不含值的联接结果展示 API 密钥状态:确认已配置的字面密钥或引用凭据显示为绿色实心点,确认缺失的具名引用显示为红色实心点,无引用的提供方原生认证或无法取得凭据补充信息时则不显示状态点。每个状态点都有无障碍文案和工具提示;「应用」成功后的本地状态消息会使用同一个提供方标识,且绝不回显任何机密内容。 +只有当联接所得的行识别出该页面派生的精确 `_API_KEY` 引用,并将其报告为已配置且可写时,删除操作才会清除该凭据。它会先取消设置该凭据,再取消设置用户层 profile;如果 settings 阶段失败,该行及其已冻结的目标仍可见,便于重试。两项 unset 都具备幂等性。自定义引用、环境凭据、缺失的凭据,以及联接无法识别目标的凭据均会保留。行的无障碍 Edit/Delete 名称以及破坏性对话框的标题、说明和最终操作都使用同一个稳定的 `Display Name (route-id)` 标识;当两个字符串相同时,标识会简化为路由 id。对话框会说明是否一并删除已存密钥,并在自身内显示操作失败,而不是用加载错误横幅替换整个页面。行只根据不含值的联接结果展示 API 密钥状态:确认已配置的引用凭据显示为绿色实心点,确认缺失的具名引用显示为红色实心点,无引用的提供方原生认证或无法取得凭据补充信息时则不显示状态点。每个状态点都有无障碍文案和工具提示;「应用」成功后的本地状态消息会使用同一个提供方标识,且绝不回显任何机密内容。 ## 曾考虑的替代方案 @@ -20,8 +20,8 @@ Models 编辑器横跨互相独立的 settings 与凭据 RPC 领域。之前它 **删除被移除 profile 所指定的每一个凭据引用。**自定义引用可能被共享、由外部管理,或有意在 profile 反复增删时存留。与该页面派生目标精确相等,再加上已配置且可写的状态,是页面所能获得的最小范围证据;比这更弱的判定都有可能删除不属于它的凭据。 -**先删除 settings,再重建 profile 以作补偿。**浏览器只持有脱敏后的子树,无法忠实重建已存的字面机密或并发编辑。先删除凭据可以让权威 profile 在部分失败时仍然可见,并且无需合成配置就能安全重试。 +**先删除 settings,再重建 profile 以作补偿。**浏览器只持有脱敏后的子树,无法忠实重建并发编辑。先删除凭据可以让权威 profile 在部分失败时仍然可见,并且无需合成配置就能安全重试。 ## 后果 -Models 页可以从任一第二阶段失败中恢复,无需重新加载,也不会泄露机密或产生虚假的并发冲突;空密钥的 pi-ai profile 会保留 Bedrock、Vertex 与其他提供方原生认证。已确认的状态清晰可见,同时不会把路由存活状态、原生认证或凭据查询失败误报为错误;即使该行继续显示绿色,密钥替换成功也仍然可观察。删除由页面管理的提供方不再遗留可重用的本地密钥,而存在歧义的凭据会有意保留,交由手动管理。保存与删除在跨持久存储时仍非原子操作:进程可能在两个阶段之间崩溃,但它们的顺序与幂等性会留下可观察、可重试的状态。组件测试固定了部分成功后的重试、空密钥原生认证、标准化字面值、状态可见性、目标标识、清理所有权,以及凭据/settings 拒绝顺序;无密钥的浏览器场景固定了双语无障碍文案,并验证确认删除会同时清除 `settings.yaml` profile 与 `.env` 凭据。此决策细化了 [web 配置平面 note](../architecture/2026-07-30-web-config-plane.md) 中记录的 Models 应用语义。 +Models 页可以从任一第二阶段失败中恢复,无需重新加载,也不会泄露机密或产生虚假的并发冲突;空密钥的 pi-ai profile 会保留 Bedrock、Vertex 与其他提供方原生认证。已确认的状态清晰可见,同时不会把路由存活状态、原生认证或凭据查询失败误报为错误;即使该行继续显示绿色,密钥替换成功也仍然可观察。删除由页面管理的提供方不再遗留可重用的本地密钥,而存在歧义的凭据会有意保留,交由手动管理。保存与删除在跨持久存储时仍非原子操作:进程可能在两个阶段之间崩溃,但它们的顺序与幂等性会留下可观察、可重试的状态。组件测试固定了部分成功后的重试、空密钥原生认证、密钥首尾空白处理、状态可见性、目标标识、清理所有权,以及凭据/settings 拒绝顺序;无密钥的浏览器场景固定了双语无障碍文案,并验证确认删除会同时清除 `settings.yaml` profile 与 `.credentials.yaml` 条目。此决策细化了 [web 配置平面 note](../architecture/2026-07-30-web-config-plane.md) 中记录的 Models 应用语义。 diff --git a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.i18n.yaml index 34baccb6f5..418c81f17d 100644 --- a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.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 .agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md -2026-07-30-deepseek-onboarding-credential-setup.md: c732758bc567376a0be4ac348aa9129aa8126ac4 -2026-07-30-deepseek-onboarding-credential-setup.zh.md: 2dc7e0ccf5f9a99ad35c859a7ecfb9f98d93d530 +2026-07-30-deepseek-onboarding-credential-setup.md: 419ea0aea56e82e301189d90d5ca78495da2da71 +2026-07-30-deepseek-onboarding-credential-setup.zh.md: 936402c9ed83eccc3d5e78a57247f06347522c0f diff --git a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md index c732758bc5..419ea0aea5 100644 --- a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md +++ b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md @@ -10,7 +10,7 @@ The [web configuration plane](../architecture/2026-07-30-web-config-plane.md) ma ## Decision -**One readiness projection owns both Models and onboarding facts.** `ui-models` keeps a single store that joins `llm.providers({})`, redacted `settings.describe({})`, and batched `credentials.describe({refs})`. The onboarding projection selects the `deepseek-official` configurable-provider entry owned by the `llm-deepseek` namespace and empty settings path, reads the effective `apiKeyEnv`, and evaluates the matching credential descriptor. A live route with the same provider id but no matching configurable-provider declaration is adapter-absent for onboarding. A configured literal `apiKey` secret sidecar is also ready, so compatibility configuration does not trigger a false prompt; a configured process-environment credential is ready and remains read-only. +**One readiness projection owns both Models and onboarding facts.** `ui-models` keeps a single store that joins `llm.providers({})`, redacted `settings.describe({})`, and batched `credentials.describe({refs})`. The onboarding projection selects the `deepseek-official` configurable-provider entry owned by the `llm-deepseek` namespace and empty settings path, reads the effective `apiKeyEnv`, and evaluates the matching credential descriptor. A live route with the same provider id but no matching configurable-provider declaration is adapter-absent for onboarding. A configured process-environment credential is ready and remains read-only. **The settings shell contributes ordering and navigation, not provider policy.** `ui-settings` declares a root-scoped `settings.onboarding` list slot and mounts one ordered step at a time while the current surface is the empty Hero. The active registrant receives `complete()` and a private `openSection(id)` callback; completion transfers ownership to the next entry. `ui-models` registers the DeepSeek step and its Models section through `slots.inject()`, so each contribution follows its declaration lifetime without making plugin load order a contract, and independently contributed dialogs cannot stack. The product-wide welcome step that precedes it is owned separately by [the versioned welcome decision](2026-07-30-versioned-gui-welcome-onboarding.md). @@ -30,4 +30,4 @@ The [web configuration plane](../architecture/2026-07-30-web-config-plane.md) ma ## Consequences -The ordered flow leads from the product notice to the shipped adapter's existing editor without restarting: a keyless browser test boots the real Web composition under an isolated harness home, acknowledges the notice, follows the DeepSeek page to Models, stores a generated key through that page into the home's `.env`, verifies no key reaches DOM, ARIA, or browser console output, and confirms the running page reports configured. The full keyless Web replay lane also pins that a non-configurable replay route with the same provider id does not block unrelated journeys. Pure readiness and React tests pin literal, file, process-environment, missing-provider, missing-capability, navigation, cancellation, external-invalidation, and coordinator-transfer behavior. The flow deliberately inherits the configuration plane's documented base limitations rather than adding local secret storage, redaction, or settings replacement workarounds. +The ordered flow leads from the product notice to the shipped adapter's existing editor without restarting: a keyless browser test boots the real Web composition under an isolated harness home, acknowledges the notice, follows the DeepSeek page to Models, stores a generated key through that page into the home's `.credentials.yaml`, verifies no key reaches DOM, ARIA, or browser console output, and confirms the running page reports configured. The full keyless Web replay lane also pins that a non-configurable replay route with the same provider id does not block unrelated journeys. Pure readiness and React tests pin managed-file and process-environment credentials, missing providers and capabilities, navigation, cancellation, external invalidation, and coordinator transfer. The flow deliberately inherits the configuration plane's documented base limitations rather than adding local secret storage, redaction, or settings replacement workarounds. diff --git a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.zh.md b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.zh.md index 2dc7e0ccf5..936402c9ed 100644 --- a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -**Models 与首次使用引导共享同一个就绪状态投影。**`ui-models` 维护一个 store,把 `llm.providers({})`、脱敏后的 `settings.describe({})` 和批量调用的 `credentials.describe({refs})` 联接为同一份状态。首次使用投影选取由 `llm-deepseek` namespace 与空 settings path 持有的 `deepseek-official` 可配置提供方条目,读取生效的 `apiKeyEnv`,并检查对应的凭据描述符。同 provider id 但没有匹配可配置提供方声明的存活路由,在首次使用引导中视为适配器缺失。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,也会判定为就绪,兼容配置因此不会误触发页面;通过进程环境提供的凭据若已配置,同样判定为就绪并保持只读。 +**Models 与首次使用引导共享同一个就绪状态投影。**`ui-models` 维护一个 store,把 `llm.providers({})`、脱敏后的 `settings.describe({})` 和批量调用的 `credentials.describe({refs})` 联接为同一份状态。首次使用投影选取由 `llm-deepseek` namespace 与空 settings path 持有的 `deepseek-official` 可配置提供方条目,读取生效的 `apiKeyEnv`,并检查对应的凭据描述符。同 provider id 但没有匹配可配置提供方声明的存活路由,在首次使用引导中视为适配器缺失。通过进程环境提供的凭据若已配置,则判定为就绪并保持只读。 **设置外壳只贡献排序与导航,不持有提供方策略。** `ui-settings` 声明一个根作用域的 `settings.onboarding` list slot,并在当前界面为空白 Hero 时,每次只挂载一个有序步骤。当前注册方会收到 `complete()` 和私有 `openSection(id)` 回调;完成当前步骤后,所有权转交给下一项。`ui-models` 通过 `slots.inject()` 注册 DeepSeek 步骤及其 Models 分区,使每项贡献都跟随自身的声明生命周期,不让插件加载顺序成为契约;独立贡献的对话框也无法堆叠。排在它之前的产品级欢迎步骤由[版本化欢迎决策](2026-07-30-versioned-gui-welcome-onboarding.md)单独持有。 @@ -30,4 +30,4 @@ Status: implemented ## 后果 -有序流程从产品声明页开始,无需重启即可引导用户前往随产品提供的适配器已有的编辑器:无密钥浏览器测试在隔离的 harness 家目录下启动真实 Web 组合,确认声明后依照 DeepSeek 页面前往 Models,通过该页面把生成的密钥存入该目录的 `.env`,验证密钥未进入 DOM、ARIA 或浏览器控制台输出,并确认运行中的页面报告已配置。完整的无密钥 Web 回放也固定了同 id 的不可配置回放路由不会阻塞无关流程。纯就绪状态测试与 React 测试固化了字面量凭据、文件凭据、进程环境凭据、提供方缺失、能力缺失、导航、取消、外部失效和协调器移交行为。该流程直接继承配置平面已记录的基础限制,不会另加局部的机密存储、脱敏或设置替换变通方案。 +有序流程从产品声明页开始,无需重启即可引导用户前往随产品提供的适配器已有的编辑器:无密钥浏览器测试在隔离的 harness 家目录下启动真实 Web 组合,确认声明后依照 DeepSeek 页面前往 Models,通过该页面把生成的密钥存入该目录的 `.credentials.yaml`,验证密钥未进入 DOM、ARIA 或浏览器控制台输出,并确认运行中的页面报告已配置。完整的无密钥 Web 回放也固定了同 id 的不可配置回放路由不会阻塞无关流程。纯就绪状态测试与 React 测试固化了受管文件凭据与进程环境凭据、提供方与能力缺失、导航、取消、外部失效和协调器移交。该流程直接继承配置平面已记录的基础限制,不会另加局部的机密存储、脱敏或设置替换变通方案。 diff --git a/examples/acp-agent/tests/fs-search.cordis.yml b/examples/acp-agent/tests/fs-search.cordis.yml index c86b34b8aa..9f9ac7cf0c 100644 --- a/examples/acp-agent/tests/fs-search.cordis.yml +++ b/examples/acp-agent/tests/fs-search.cordis.yml @@ -2,8 +2,6 @@ - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_BASE_URL models: - id: deepseek-v4-pro diff --git a/examples/acp-agent/tests/pwsh.cordis.yml b/examples/acp-agent/tests/pwsh.cordis.yml index 7021ae2116..570d98bf4d 100644 --- a/examples/acp-agent/tests/pwsh.cordis.yml +++ b/examples/acp-agent/tests/pwsh.cordis.yml @@ -2,8 +2,6 @@ - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_BASE_URL models: - id: deepseek-v4-pro diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 624e9e37af..8afbd1d248 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -35,11 +35,6 @@ # once the web UI owns the choice per session. mode: !!js process.env.DSH_TOOLS_MODE -- id: llm-deepseek - config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_BASE_URL - # ── web-only host rows, the transport layer, and the browser roster ───────── # `dshClient` rows are the browser roster the modules node half scans into diff --git a/packages/client/ui-models/src/client/ModelsSection.tsx b/packages/client/ui-models/src/client/ModelsSection.tsx index b5a2801bf5..3830710df1 100644 --- a/packages/client/ui-models/src/client/ModelsSection.tsx +++ b/packages/client/ui-models/src/client/ModelsSection.tsx @@ -80,8 +80,8 @@ function renderProviderEditor({ target, ...props }: ProviderEditorRenderProps): * Remove one user-added provider and its page-managed credential. Credential * removal comes first so a second-step failure leaves the provider row visible * and the whole operation safely retryable; both unsets are idempotent. - * The settings removal names the profile rather than rebuilding its redacted - * namespace, which would drop literal secrets stored elsewhere. + * The settings removal names the profile rather than rebuilding its whole + * namespace from a partial view. * @param api - settings and credential wire faces. * @param controller - the page store to refresh. * @param target - the provider's settings address and optional managed credential. @@ -112,16 +112,14 @@ export async function removeProviderProfile( } /** - * Whether a whole-section provider still needs its first key: nothing marks - * the credential configured and no literal `apiKey` is stored, so the page - * opens the setup card instead of showing a row. + * Whether a whole-section provider still needs its first key: an unconfigured + * credential opens the setup card instead of showing a row. * @param row - the joined provider row. * @returns whether to render the setup card. */ export function needsSetup(row: ProviderRow): boolean { if (row.entry.settingsPath.length > 0) return false - if (row.credential?.configured === true) return false - return !row.literalApiKeyConfigured + return row.credential?.configured !== true } function targetOf(row: ProviderRow): EditorTarget { @@ -264,7 +262,7 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { ) } const open = !adding && editing?.provider === row.entry.provider - const credentialConfigured = row.literalApiKeyConfigured || row.credential?.configured === true + const credentialConfigured = row.credential?.configured === true const credentialMissing = !credentialConfigured && row.apiKeyEnv !== undefined && row.credential?.configured === false diff --git a/packages/client/ui-models/src/client/ProviderEditor.tsx b/packages/client/ui-models/src/client/ProviderEditor.tsx index e4cef56250..5020e024d5 100644 --- a/packages/client/ui-models/src/client/ProviderEditor.tsx +++ b/packages/client/ui-models/src/client/ProviderEditor.tsx @@ -10,9 +10,8 @@ * both families, `reasoningEffort` for deepseek / `reasoning` for pi-ai, and * DeepSeek's id/name/context-window model catalog). Everything else stays * owned by `settings.yaml`. Profile edits land as minimal `settings.mutate` - * path ops against the stored section — the card reads the redacted - * descriptor, so it names only the fields it can see and a stored literal - * secret is never collaterally removed. + * path ops against the stored section — the card names only the fields it can + * see instead of rebuilding the whole subtree from a partial descriptor. */ import { useEffect, useMemo, useState } from 'react' @@ -80,10 +79,9 @@ function draftAt(namespace: SettingsNamespaceView, path: readonly string[]): Rec /** * The minimal path ops carrying `after` over `before`, both as the card sees - * them (that is, redacted). Only keys the card observed are named: a stored - * `role('secret')` field appears in neither side, so it produces no op and - * survives the write — the whole reason edits are path-addressed rather than - * a rebuilt section. + * them. Only keys the card observed are named; fields absent from both sides + * produce no op, which is why edits are path-addressed rather than a rebuilt + * section. * @param base - path of the edited subtree inside the user section. * @param before - the subtree as loaded, or undefined when it is new. * @param after - the subtree as edited. @@ -205,9 +203,8 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { /** * The write for this card, or a failure message. Every edit travels as * path ops against the STORED section: the draft comes from the redacted - * descriptor, so a wholesale replace rebuilt from it would delete the - * literal secrets the wire never returned. Ops name only the fields this - * card can see, so a stored secret is untouched by construction. + * descriptor, so a wholesale replace rebuilt from it could delete fields + * outside the card. Ops name only the fields this card can see. */ const applyOnce = async (): Promise => { const ns = namespace.ns diff --git a/packages/client/ui-models/src/client/store.ts b/packages/client/ui-models/src/client/store.ts index 938283b903..95db7e6787 100644 --- a/packages/client/ui-models/src/client/store.ts +++ b/packages/client/ui-models/src/client/store.ts @@ -31,8 +31,6 @@ export interface ProviderRow { apiKeyEnv: string | undefined /** Credential state for {@link apiKeyEnv}, once described. */ credential: CredentialView | undefined - /** Whether the redacted secret sidecar reports an effective literal `apiKey`. */ - literalApiKeyConfigured: boolean } /** Page snapshot. */ @@ -97,19 +95,6 @@ function apiKeyEnvOf(namespace: SettingsNamespaceView | undefined, path: readonl return typeof ref === 'string' && ref.length > 0 ? ref : undefined } -/** Whether one namespace's redacted sidecar reports a set literal API key. */ -function literalApiKeyConfigured( - namespace: SettingsNamespaceView | undefined, - path: readonly string[], -): boolean { - if (namespace === undefined) return false - const secretPath = [...path, 'apiKey'] - return namespace.secrets.some(secret => - secret.set - && secret.path.length === secretPath.length - && secret.path.every((key, index) => key === secretPath[index])) -} - /** The models settings page controller (one per settings surface). */ export class ModelsSettingsStore { /** The snapshot the section renders from (uSES-safe store). */ @@ -170,7 +155,6 @@ export class ModelsSettingsStore { removable, apiKeyEnv: apiKeyEnvOf(namespace, entry.settingsPath), credential: undefined, - literalApiKeyConfigured: literalApiKeyConfigured(namespace, entry.settingsPath), } }) const refs = [...new Set(rows.flatMap(row => row.apiKeyEnv === undefined ? [] : [row.apiKeyEnv]))] @@ -257,7 +241,6 @@ export function deepSeekReadiness(state: ModelsSettingsState): DeepSeekReadiness reason: 'settings-unavailable', } } - if (row.literalApiKeyConfigured) return { kind: 'configured' } if (row.apiKeyEnv === undefined) { return { kind: 'unavailable', diff --git a/packages/client/ui-models/tests/components.spec.tsx b/packages/client/ui-models/tests/components.spec.tsx index 931410fb35..be798bd495 100644 --- a/packages/client/ui-models/tests/components.spec.tsx +++ b/packages/client/ui-models/tests/components.spec.tsx @@ -35,9 +35,7 @@ function capacityInputs(label: string): HTMLInputElement[] { } const PiAiConfig = Schema.object({ - token: Schema.string().role('secret'), providers: Schema.dict(Schema.object({ - apiKey: Schema.string().role('secret'), apiKeyEnv: Schema.string().role('credential-ref'), baseURL: Schema.string(), reasoning: Schema.union(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']), @@ -46,7 +44,6 @@ const PiAiConfig = Schema.object({ }) const DeepSeekConfig = Schema.object({ - apiKey: Schema.string().role('secret'), apiKeyEnv: Schema.string().role('credential-ref'), baseURL: Schema.string().pattern(/^https:\/\//), reasoningEffort: Schema.union(['off', 'high', 'max']), @@ -100,7 +97,7 @@ function wireNamespaces(): SettingsNamespaceView[] { base: { defaultContextWindow: 1_000_000, maxTokens: 256_000, models: DEFAULT_DEEPSEEK_MODELS }, user: { reasoningEffort: 'high' }, applies: 'live', - secrets: [{ path: ['apiKey'], set: false }], + secrets: [], revision: 0, }, { @@ -119,7 +116,7 @@ function wireNamespaces(): SettingsNamespaceView[] { value: { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://proxy', headers: { 'X-Team': 'a' } }, zombie: {} } }, user: { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://proxy', headers: { 'X-Team': 'a' } }, zombie: {} } }, applies: 'live', - secrets: [{ path: ['token'], set: false }, { path: ['providers', 'openai', 'apiKey'], set: false }], + secrets: [], revision: 0, }, ] @@ -263,22 +260,17 @@ describe('ModelsSection', () => { expect(screen.queryByLabelText(en.keyInput)).toBeNull() }) - it('decides setup need from the joined credential state and literal-key sidecar', () => { + it('decides setup need from the joined credential state', () => { const entry = { provider: 'p', displayName: 'p', settingsNs: 'llm-deepseek', settingsPath: [], active: true } - const row = ( - credential: ProviderRow['credential'], - literalApiKeyConfigured = false, - ): ProviderRow => ({ + const row = (credential: ProviderRow['credential']): ProviderRow => ({ entry, configured: true, removable: false, apiKeyEnv: 'X', credential, - literalApiKeyConfigured, }) expect(needsSetup(row(undefined))).toBe(true) expect(needsSetup(row({ configured: true, writable: true }))).toBe(false) - expect(needsSetup(row(undefined, true))).toBe(false) const nested = { ...row(undefined), entry: { ...entry, settingsPath: ['providers', 'x'] } } expect(needsSetup(nested)).toBe(false) }) @@ -295,9 +287,7 @@ describe('ModelsSection', () => { expect(providerTargetLabel(OPENAI_TARGET)).toBe('openai') }) - it('names only the fields the card can see, so an unseen secret survives', () => { - // `before` is the REDACTED subtree: a stored literal apiKey is in neither - // side, so no op mentions it and the seam leaves it alone. + it('names only changed fields instead of rebuilding the section', () => { expect(pathOps(['providers', 'openai'], { baseURL: 'https://old', reasoning: 'high' }, { reasoning: 'high' })) .toEqual([{ op: 'unset', path: ['providers', 'openai', 'baseURL'] }]) expect(pathOps([], { b: 1 }, { b: 2, d: 3 })) @@ -725,8 +715,7 @@ describe('ModelsSection', () => { }) it('clears an inherited override with an unset op, never a whole-section replace', async () => { - // The data-loss shape: the old path rebuilt the section from the REDACTED - // user layer and replaced it wholesale, deleting any stored literal key. + // The old path rebuilt the whole user section to clear one inherited field. const { replace, update, mutate } = await mountSection() fireEvent.click(screen.getByText(en.customized)) const effort = screen.getByLabelText(en.effort) @@ -800,9 +789,7 @@ describe('ModelsSection', () => { fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement) await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) }) // Only the edited field travels: apiKeyEnv, baseURL and headers were - // already stored with these values, so no op restates them — and the - // profile's stored literal apiKey, absent from the redacted view the card - // read, is named by nothing at all. + // already stored with these values, so no op restates them. expect(mutate.mock.calls[0]?.[0]).toEqual({ ns: 'llm-pi-ai', ops: [{ op: 'set', path: ['providers', 'openai', 'reasoning'], value: 'xhigh' }], @@ -1134,8 +1121,8 @@ describe('ModelsSection', () => { }) it('removes by unsetting the profile path, never by rebuilding the section', async () => { - // The section rebuild is what dropped stored literal secrets: this page - // only ever holds the redacted descriptor, so the removal names the path. + // The page only needs to name the profile path; rebuilding the section + // would widen the write for no benefit. const { face, mutate, replace, controller } = await mountSection() await removeProviderProfile( face as unknown as Parameters[0], diff --git a/packages/client/ui-models/tests/onboarding-dialog.spec.tsx b/packages/client/ui-models/tests/onboarding-dialog.spec.tsx index 07acc89fe4..772e14aaba 100644 --- a/packages/client/ui-models/tests/onboarding-dialog.spec.tsx +++ b/packages/client/ui-models/tests/onboarding-dialog.spec.tsx @@ -28,7 +28,6 @@ function harness(options: { providerActive?: boolean settingsNamespace?: boolean apiKeyEnv?: string | null - literal?: boolean configured?: () => boolean credential?: { source?: string; writable: boolean } describeFailure?: string @@ -66,7 +65,7 @@ function harness(options: { ? {} : { apiKeyEnv: options.apiKeyEnv ?? 'DEEPSEEK_API_KEY' }, applies: 'live' as const, - secrets: [{ path: ['apiKey'], set: options.literal === true }], + secrets: [], revision: 0, }], })), @@ -153,11 +152,10 @@ describe('DeepSeekOnboardingDialog', () => { } }) - it('skips an absent adapter and already-configured literal or environment credentials', async () => { + it('skips an absent adapter and an already-configured environment credential', async () => { for (const h of [ harness({ provider: false }), harness({ providerSettingsNs: '' }), - harness({ literal: true, describeFailure: 'credential seam absent' }), harness({ configured: () => true, credential: { source: 'env', writable: false } }), ]) { const view = render() diff --git a/packages/client/ui-models/tests/readiness.spec.ts b/packages/client/ui-models/tests/readiness.spec.ts index d03cd130f4..f01ab75930 100644 --- a/packages/client/ui-models/tests/readiness.spec.ts +++ b/packages/client/ui-models/tests/readiness.spec.ts @@ -19,7 +19,6 @@ function row(overrides: Partial = {}): ProviderRow { removable: false, apiKeyEnv: 'DEEPSEEK_API_KEY', credential: missingCredential, - literalApiKeyConfigured: false, ...overrides, } } @@ -64,13 +63,6 @@ describe('deepSeekReadiness', () => { }))).toEqual({ kind: 'configured' }) }) - it('accepts the redacted literal-key sidecar before judging the credential domain', () => { - expect(deepSeekReadiness(state({ - credentialError: 'credentials service absent', - rows: [row({ literalApiKeyConfigured: true, credential: undefined })], - }))).toEqual({ kind: 'configured' }) - }) - it('turns missing capabilities and inconsistent descriptors into diagnostics', () => { expect(deepSeekReadiness(state({ status: 'error', error: 'settings down' }))).toEqual({ kind: 'unavailable', diff --git a/packages/client/ui-models/tests/store.spec.ts b/packages/client/ui-models/tests/store.spec.ts index ee9aa2ddaf..5a1f340a0d 100644 --- a/packages/client/ui-models/tests/store.spec.ts +++ b/packages/client/ui-models/tests/store.spec.ts @@ -25,7 +25,7 @@ const NAMESPACES = [ value: { apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://base' }, base: { baseURL: 'https://base' }, applies: 'live' as const, - secrets: [{ path: ['apiKey'], set: false }], + secrets: [], revision: 0, }, { @@ -85,7 +85,6 @@ describe('ModelsSettingsStore', () => { removable: false, apiKeyEnv: 'DEEPSEEK_API_KEY', credential: { configured: false, writable: true }, - literalApiKeyConfigured: false, }) expect(byProvider.get('openai')).toMatchObject({ configured: true, @@ -131,30 +130,6 @@ describe('ModelsSettingsStore', () => { expect(store.store.getSnapshot().credentialError).toBe('credential transport refusal') }) - it('joins a configured literal key from the redacted secret sidecar', async () => { - const { face } = api({ - describeSettings: () => Promise.resolve(ok({ - writable: true, - hasDocument: false, - namespaces: [{ - ...NAMESPACES[0], - secrets: [ - { path: ['apiKey', 'nested'], set: true }, - { path: ['different'], set: true }, - { path: ['apiKey'], set: true }, - ], - }] as never, - })), - providers: () => Promise.resolve(ok({ providers: [DIRECTORY[0]] as never })), - }) - const store = new ModelsSettingsStore(face) - await store.load() - expect(store.store.getSnapshot().rows[0]).toMatchObject({ - literalApiKeyConfigured: true, - apiKeyEnv: 'DEEPSEEK_API_KEY', - }) - }) - it('surfaces a directory failure and keeps the last good rows', async () => { const { face } = api() const store = new ModelsSettingsStore(face) diff --git a/packages/examples/acp-demo/tests/load-path.e2e.ts b/packages/examples/acp-demo/tests/load-path.e2e.ts index 37b41cedd0..4719c161fe 100644 --- a/packages/examples/acp-demo/tests/load-path.e2e.ts +++ b/packages/examples/acp-demo/tests/load-path.e2e.ts @@ -33,8 +33,6 @@ const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.m const CORDIS_YML = ` - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' - config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - id: subprocess name: '@deepseek-ai/dsh-subprocess-local' - id: bash diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index ae167329a1..dbf0eb83b4 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -871,8 +871,7 @@ describe('plugin registration and config', () => { await expect(ctx.llm.listModels('deepseek-official')).resolves.toHaveLength(2) const first = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) expect(first.finish).toMatchObject({ kind: 'error', failure: { code: 'MISSING_CREDENTIAL' } }) - // The guidance leads with the credential store — the path that keeps the - // secret out of configuration files — and mentions a literal key last. + // The guidance leads with the managed credential store. const second = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) expect(second.finish.kind).toBe('error') if (second.finish.kind !== 'error') throw new Error('expected an error finish') diff --git a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts index 57b588ba7b..99e57d10c4 100644 --- a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts @@ -78,23 +78,6 @@ describe('request-level dynamic configuration', () => { expect(serverB.headers[0]?.authorization).toBe('Bearer second-key') }) - it('refuses a literal apiKey in settings and keeps serving the stored credential', async () => { - vi.stubEnv('DEEPSEEK_API_KEY', '') - const dir = await home() - await writeFile(join(dir, '.credentials.yaml'), 'DEEPSEEK_API_KEY: file-key\n', { mode: 0o600 }) - const server = await mockServer([{ kind: 'sse', events: textEvents }]) - const { ctx } = await boot(dir, { baseURL: server.url }) - - // Configuration carries a reference, never a value. The namespace has no - // `apiKey` field, so writing one is dropped by the schema rather than - // rejected (no adapter namespace is strict); what matters is that a - // settings document cannot become a second credential store outranking - // `.credentials.yaml` and the environment. - await ctx.settings.update(NS, { apiKey: 'literal-key' }) - await prompt(ctx) - expect(server.headers[0]?.authorization).toBe('Bearer file-key') - }) - it('starts keyless and serves the next request once the key arrives', async () => { vi.stubEnv('DEEPSEEK_API_KEY', '') const dir = await home() diff --git a/packages/sdk/create-sdk/tests/create.spec.ts b/packages/sdk/create-sdk/tests/create.spec.ts index a099b38b23..6c2995ec7d 100644 --- a/packages/sdk/create-sdk/tests/create.spec.ts +++ b/packages/sdk/create-sdk/tests/create.spec.ts @@ -381,7 +381,7 @@ describe('CreateWizard and scaffolder', () => { }).run() await scaffoldProject(resolved.directory, resolved.request) expect(await readFile(join(resolved.directory, '.env'), 'utf8')).toBe( - '# Required before start; an empty value makes provider startup fail.\nDEEPSEEK_API_KEY=\n', + '# Required before the first model request.\nDEEPSEEK_API_KEY=\n', ) expect(port.requests).toContain('Keep the API key empty and fill .env later?') }) diff --git a/packages/sdk/helper/src/features/builtin/provider.ts b/packages/sdk/helper/src/features/builtin/provider.ts index 94ea8a9679..a72daff94b 100644 --- a/packages/sdk/helper/src/features/builtin/provider.ts +++ b/packages/sdk/helper/src/features/builtin/provider.ts @@ -4,7 +4,6 @@ * @module @deepseek-ai/dsh-helper/features/builtin/provider */ -import { JsExpression } from '../../documents/cordis-yaml-file.ts' import { featureId } from '../../ids.ts' import type { FeatureSelection, ProjectProfile } from '../../project/types.ts' import { @@ -17,7 +16,7 @@ import { npmCordisConfigEntry, environment } from './helpers.ts' const ID = featureId('provider') const DEFAULT_MODEL = 'deepseek-v4-flash' -const API_KEY_COMMENT = 'Required before start; an empty value makes provider startup fail.' +const API_KEY_COMMENT = 'Required before the first model request.' class DeepSeekOption extends FeatureOption { override readonly id = 'deepseek-official' @@ -34,8 +33,7 @@ class DeepSeekOption extends FeatureOption { ...npmCordisConfigEntry(ID, { id: 'llm-deepseek', name: '@deepseek-ai/dsh-llm-deepseek', - config: { apiKey: new JsExpression('process.env.DEEPSEEK_API_KEY') }, - }, ['apiKey', 'baseURL', 'models']), + }, ['baseURL', 'models']), environment(ID, 'DEEPSEEK_API_KEY', secrets.apiKey, API_KEY_COMMENT), ]) } @@ -60,8 +58,7 @@ class CustomOption extends FeatureOption { ...npmCordisConfigEntry(ID, { id: 'llm-pi-ai', name: '@deepseek-ai/dsh-llm-pi-ai', - config: { apiKey: new JsExpression('process.env.DEEPSEEK_API_KEY') }, - }, ['apiKey', 'baseURL', 'models']), + }, ['baseURL', 'models']), environment(ID, 'DEEPSEEK_API_KEY', secrets.apiKey, API_KEY_COMMENT), ]) } diff --git a/packages/sdk/helper/tests/documents.spec.ts b/packages/sdk/helper/tests/documents.spec.ts index 86e50ca8ad..e3ffe18b77 100644 --- a/packages/sdk/helper/tests/documents.spec.ts +++ b/packages/sdk/helper/tests/documents.spec.ts @@ -86,20 +86,20 @@ config: expect(flow.serialize()).not.toContain('{') const document = CordisYamlFile.parse(`# lead - id: provider - name: '@deepseek-ai/dsh-llm-deepseek' + name: 'provider-package' config: - apiKey: !!js process.env.DEEPSEEK_API_KEY + endpoint: !!js process.env.PROVIDER_URL custom: keep `) - const apiKey = document.entry('provider')?.config?.apiKey - expect(apiKey).toBeInstanceOf(JsExpression) - document.updateOwnedConfig('provider', ['apiKey'], { apiKey: new JsExpression('process.env.NEXT_KEY') }) + const endpoint = document.entry('provider')?.config?.endpoint + expect(endpoint).toBeInstanceOf(JsExpression) + document.updateOwnedConfig('provider', ['endpoint'], { endpoint: new JsExpression('process.env.NEXT_URL') }) document.setDisabled('provider', true) document.addEntry({ id: 'tool', name: 'demo-tool' }) document.validate() const text = document.serialize() expect(text).toContain('# lead') - expect(text).toContain('!!js process.env.NEXT_KEY') + expect(text).toContain('!!js process.env.NEXT_URL') expect(text).toContain('custom: keep') expect(document.removeEntry('tool')).toBe(true) expect(document.removeEntry('tool')).toBe(false) diff --git a/packages/sdk/helper/tests/project.spec.ts b/packages/sdk/helper/tests/project.spec.ts index 20446216ab..d0f275ca4c 100644 --- a/packages/sdk/helper/tests/project.spec.ts +++ b/packages/sdk/helper/tests/project.spec.ts @@ -193,6 +193,7 @@ describe('SdkProject and ProjectEditSession', () => { expect(project.packageManifest().dependencies).not.toHaveProperty('@deepseek-ai/dsh-scope/invariant') expect(project.packageManifest().dependencies).not.toHaveProperty('node-addon-require-builtin') expect(project.cordis.entry('hmr')).toMatchObject({ name: '@cordisjs/plugin-hmr' }) + expect(project.cordis.entry('llm-deepseek')).not.toHaveProperty('config.apiKey') expect(project.cordis.entry('llm-deepseek')?.config).not.toHaveProperty('baseURL') expect(project.cordis.entry('llm-deepseek')?.config).not.toHaveProperty('models') }) @@ -580,7 +581,7 @@ describe('SdkProject and ProjectEditSession', () => { await writeFile(join(partialRoot, 'cordis.yml'), `- id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' config: - apiKey: test + apiKeyEnv: DEEPSEEK_API_KEY `) const partial = await SdkProject.open(partialRoot) const installation = createBuiltinRegistry(partial.profile) diff --git a/packages/sdk/scripts/tests/scripts.spec.ts b/packages/sdk/scripts/tests/scripts.spec.ts index 35df90ce98..6d2ea27991 100644 --- a/packages/sdk/scripts/tests/scripts.spec.ts +++ b/packages/sdk/scripts/tests/scripts.spec.ts @@ -535,7 +535,7 @@ describe('ConfigWorkflow', () => { ]), outputBuffer().stream, async () => {}) const result = await workflow.run(project, registry) const provider = result.commit?.project.cordis.entry('llm-pi-ai') - expect(provider?.config?.apiKey).toBeDefined() + expect(provider?.config).not.toHaveProperty('apiKey') expect(provider?.config?.baseURL).toBe('https://provider.example/v1') expect(result.commit?.project.cordis.entry('acp')).toBeDefined() expect(result.commit?.project.cordis.entry('agent-loop')).toBeDefined() diff --git a/packages/sdk/telemetry/tests/consent-resolver.spec.ts b/packages/sdk/telemetry/tests/consent-resolver.spec.ts index ca0cec3bbd..05442bcc0f 100644 --- a/packages/sdk/telemetry/tests/consent-resolver.spec.ts +++ b/packages/sdk/telemetry/tests/consent-resolver.spec.ts @@ -77,7 +77,7 @@ describe('ConsentResolver cordis.yml state', () => { '- id: llm', ' name: \'@deepseek-ai/dsh-llm-deepseek\'', ' config:', - ' apiKey: !!js process.env.DEEPSEEK_API_KEY', + ' apiKeyEnv: DEEPSEEK_API_KEY', '', ].join('\n') expect(await resolver.resolve(await projectDir(yml))) From 4ee93f79449d63c0e6397ceb66f6d3b9bb2d2b7f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 7 Aug 2026 23:26:39 +0800 Subject: [PATCH 20/27] fix(config): cover shipped bundle source ownership --- packages/bundle/base/cordis.patch.yml | 1 - .../verify-config-source-ownership.spec.ts | 30 +++++++++++ scripts/verify-config-source-ownership.ts | 51 +++++++++++-------- 3 files changed, 59 insertions(+), 23 deletions(-) create mode 100644 scripts/verify-config-source-ownership.spec.ts diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index 9ba9494c1c..9b7276b5d2 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -368,7 +368,6 @@ name: '@deepseek-ai/dsh-web-search-deepseek' config: apiKeyEnv: DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_SEARCH_BASE_URL - id: tool-web name: '@deepseek-ai/dsh-tool-web' diff --git a/scripts/verify-config-source-ownership.spec.ts b/scripts/verify-config-source-ownership.spec.ts new file mode 100644 index 0000000000..41026c5fe4 --- /dev/null +++ b/scripts/verify-config-source-ownership.spec.ts @@ -0,0 +1,30 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { collectConfigSourceOwnershipViolations } from './verify-config-source-ownership.ts' + +const roots: string[] = [] + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +describe('configuration source ownership gate', () => { + it('rejects inline endpoints in shipped bundle patches', () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-config-source-ownership-')) + roots.push(root) + const directory = join(root, 'packages/bundle/base') + mkdirSync(directory, { recursive: true }) + writeFileSync( + join(directory, 'cordis.patch.yml'), + 'config:\n baseURL: !!js process.env.DEEPSEEK_SEARCH_BASE_URL\n', + ) + + expect(collectConfigSourceOwnershipViolations(root)).toEqual([ + 'packages/bundle/base/cordis.patch.yml:2: inlines a credential or endpoint from the environment.' + + ' The adapter resolves apiKeyEnv through ctx.credentials and the endpoint through the' + + ' environment snapshot; inlining here bypasses both ladders.', + ]) + }) +}) diff --git a/scripts/verify-config-source-ownership.ts b/scripts/verify-config-source-ownership.ts index ffc849bba3..e027fcba61 100644 --- a/scripts/verify-config-source-ownership.ts +++ b/scripts/verify-config-source-ownership.ts @@ -16,6 +16,7 @@ const SHIPPED_CONFIG_GLOBS = [ 'apps/*/config/*.yml', 'examples/*/*.cordis.yml', 'examples/*/cordis.yml', + 'packages/bundle/*/cordis.patch.yml', // The Python runtime ships its own default composition inside the wheel. 'python/*/src/**/cordis.yml', ] @@ -29,29 +30,35 @@ const SHIPPED_CONFIG_GLOBS = [ */ const INLINE_DENY = /^\s*(apiKey|baseURL|apiKeyEnv|authToken|headers)\s*:\s*!!js\b/ -const failures: string[] = [] - -for (const glob of SHIPPED_CONFIG_GLOBS) { - for (const file of globSync(glob, { cwd: ROOT })) { - const rel = file.split(sep).join('/') - readFileSync(resolve(ROOT, rel), 'utf8').split('\n').forEach((line, index) => { - if (!INLINE_DENY.test(line)) return - failures.push( - `${rel}:${String(index + 1)}: inlines a credential or endpoint from the environment.` - + ' The adapter resolves apiKeyEnv through ctx.credentials and the endpoint through the' - + ' environment snapshot; inlining here bypasses both ladders.', - ) - }) +/** Return every forbidden inline environment form in shipped configuration. */ +export function collectConfigSourceOwnershipViolations(root: string): string[] { + const failures: string[] = [] + for (const glob of SHIPPED_CONFIG_GLOBS) { + for (const file of globSync(glob, { cwd: root })) { + const rel = file.split(sep).join('/') + readFileSync(resolve(root, rel), 'utf8').split('\n').forEach((line, index) => { + if (!INLINE_DENY.test(line)) return + failures.push( + `${rel}:${String(index + 1)}: inlines a credential or endpoint from the environment.` + + ' The adapter resolves apiKeyEnv through ctx.credentials and the endpoint through the' + + ' environment snapshot; inlining here bypasses both ladders.', + ) + }) + } } + return failures } -if (failures.length > 0) { - process.stderr.write('verify-config-source-ownership: configuration source ownership violated:\n') - for (const failure of failures) process.stderr.write(` ${failure}\n`) - process.exit(1) -} +if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) { + const failures = collectConfigSourceOwnershipViolations(ROOT) + if (failures.length > 0) { + process.stderr.write('verify-config-source-ownership: configuration source ownership violated:\n') + for (const failure of failures) process.stderr.write(` ${failure}\n`) + process.exit(1) + } -process.stdout.write( - 'verify-config-source-ownership: no credential or endpoint uses the ordinary inline environment form' - + ' in shipped configuration.\n', -) + process.stdout.write( + 'verify-config-source-ownership: no credential or endpoint uses the ordinary inline environment form' + + ' in shipped configuration.\n', + ) +} From 95366f61976f07d7fa447d011276a076935947ac Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 7 Aug 2026 23:28:17 +0800 Subject: [PATCH 21/27] fix(cli): parse commands before loading environment --- apps/cli/src/bin.ts | 5 ++--- apps/cli/tests/built-bin.e2e.ts | 11 +++++++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts index 28ef96d004..4a209b2796 100644 --- a/apps/cli/src/bin.ts +++ b/apps/cli/src/bin.ts @@ -24,14 +24,13 @@ function readVersion(): string { return typeof manifest.version === 'string' ? manifest.version : '0.0.0' } -const environment = loadLayeredEnv('dsh') const invocation = parseDshArgs(process.argv.slice(2), readVersion()) switch (invocation.mode) { case 'profile': { const { runProfile } = await import('./profile-boot.ts') await runProfile({ - environment, + environment: loadLayeredEnv('dsh'), profile: invocation.profile, patchFiles: invocation.patches, ...invocation.task !== undefined && { task: invocation.task }, @@ -40,7 +39,7 @@ switch (invocation.mode) { } case 'web': { const { runWeb } = await import('./web.ts') - await runWeb(invocation, environment) + await runWeb(invocation, loadLayeredEnv('dsh')) break } case 'plugin': { diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index 22fe20883e..20ed3fb160 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -190,6 +190,17 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', } }, 30_000) + it('does not load a project environment for --version', async () => { + const project = mkdtempSync(join(tmpdir(), 'dsh-version-project-')) + writeFileSync(join(project, '.env'), 'PATH=/project-only-path\n') + try { + const result = await runBuiltBin(['--version'], {}, project) + expect(result).toEqual({ code: 0, stdout: '0.0.1', stderr: '' }) + } finally { + rmSync(project, { recursive: true, force: true }) + } + }) + it('fails loud on a nonexistent profile with the plugin-command hint', async () => { const home = mkdtempSync(join(tmpdir(), 'dsh-missing-profile-')) try { From 2c532f3b2c8accc038c1f9b38b3b15d0006cb3c4 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 7 Aug 2026 23:30:03 +0800 Subject: [PATCH 22/27] cleanup(environment): remove unused layer inventory --- packages/ui/app-boot/tests/app-boot.spec.ts | 29 ++++--------------- packages/util/environment/src/index.ts | 15 ---------- .../environment/tests/environment.spec.ts | 10 ------- 3 files changed, 6 insertions(+), 48 deletions(-) diff --git a/packages/ui/app-boot/tests/app-boot.spec.ts b/packages/ui/app-boot/tests/app-boot.spec.ts index 864eaa600e..447b2f5949 100644 --- a/packages/ui/app-boot/tests/app-boot.spec.ts +++ b/packages/ui/app-boot/tests/app-boot.spec.ts @@ -151,7 +151,7 @@ describe('loadLayeredEnv', () => { } }) - it('reports each layer with its absolute path', () => { + it('reports each file value with its absolute path', () => { const home = tmp() const project = tmp() writeFileSync(join(home, '.env'), `${NAMES[1]}=u\n`) @@ -160,12 +160,8 @@ describe('loadLayeredEnv', () => { vi.stubEnv('DSH_HOME', home) try { const snapshot = loadLayeredEnv(NAME, project, vi.fn()) - expect(snapshot.layers).toEqual([ - { source: 'process' }, - { source: 'project-env', path: join(project, '.env') }, - { source: 'user-env', path: join(home, '.env') }, - ]) expect(snapshot.get(NAMES[1])).toEqual({ value: 'u', source: 'user-env', path: join(home, '.env') }) + expect(snapshot.get(NAMES[2])).toEqual({ value: 'p', source: 'project-env', path: join(project, '.env') }) // getFrom is a refusal, not a demotion: an omitted layer is invisible. expect(snapshot.getFrom(NAMES[2], ['process', 'user-env'])).toBeUndefined() } finally { @@ -205,10 +201,8 @@ describe('loadLayeredEnv', () => { try { const snapshot = loadLayeredEnv(NAME, project, warn) expect(warn).toHaveBeenCalledWith(expect.stringContaining(`${NAME}: failed to load .env`)) - expect(snapshot.layers).toEqual([ - { source: 'process' }, - { source: 'project-env', path: join(project, '.env') }, - ]) + expect(snapshot.get(NAMES[1])).toBeUndefined() + expect(snapshot.get(NAMES[2])).toEqual({ value: 'project-only', source: 'project-env', path: join(project, '.env') }) expect(process.env[NAMES[2]]).toBe('project-only') } finally { clear() @@ -227,10 +221,7 @@ describe('loadLayeredEnv', () => { try { const snapshot = loadLayeredEnv(NAME, project) expect(write).toHaveBeenCalledWith(expect.stringContaining(`${NAME}: failed to load .env`)) - expect(snapshot.layers).toEqual([ - { source: 'process' }, - { source: 'project-env', path: join(project, '.env') }, - ]) + expect(snapshot.get(NAMES[2])).toEqual({ value: 'project-only', source: 'project-env', path: join(project, '.env') }) expect(process.env[NAMES[2]]).toBe('project-only') } finally { write.mockRestore() @@ -251,10 +242,7 @@ describe('loadLayeredEnv', () => { // layer is simply absent, and nothing is reported. const snapshot = loadLayeredEnv(NAME, project, warn) expect(warn).not.toHaveBeenCalled() - expect(snapshot.layers).toEqual([ - { source: 'process' }, - { source: 'project-env', path: join(project, '.env') }, - ]) + expect(snapshot.get(NAMES[2])).toEqual({ value: 'project-only', source: 'project-env', path: join(project, '.env') }) } finally { clear() vi.unstubAllEnvs() @@ -269,7 +257,6 @@ describe('loadLayeredEnv', () => { vi.stubEnv('APP_BOOT_LAYERED_INHERITED', 'inherited') try { const snapshot = loadLayeredEnv(NAME, project, vi.fn()) - expect(snapshot.layers).toEqual([{ source: 'process' }]) expect(snapshot.get('APP_BOOT_LAYERED_INHERITED')).toEqual({ value: 'inherited', source: 'process' }) } finally { clear() @@ -287,10 +274,6 @@ describe('loadLayeredEnv', () => { // is the more trusted of the two — reading it twice would otherwise // put the same path at two different ranks. const snapshot = loadLayeredEnv(NAME, both, vi.fn()) - expect(snapshot.layers).toEqual([ - { source: 'process' }, - { source: 'project-env', path: join(both, '.env') }, - ]) expect(snapshot.get(NAMES[2])).toEqual({ value: 'one-file', source: 'project-env', path: join(both, '.env') }) } finally { clear() diff --git a/packages/util/environment/src/index.ts b/packages/util/environment/src/index.ts index f35e32f9c5..6f051603e6 100644 --- a/packages/util/environment/src/index.ts +++ b/packages/util/environment/src/index.ts @@ -35,13 +35,6 @@ export interface EnvironmentEntry { path?: string } -/** One environment layer's identity, for diagnostics. */ -export interface EnvironmentLayer { - source: EnvironmentSource - /** Absolute path of the file behind this layer; absent for `process`. */ - path?: string -} - /** * The frozen environment of one launch. Construct through * {@link createEnvironmentSnapshot}; nothing mutates it afterwards, so a @@ -65,8 +58,6 @@ export interface EnvironmentSnapshot { * @returns the first matching entry, or `undefined`. */ getFrom(name: string, sources: readonly EnvironmentSource[]): EnvironmentEntry | undefined - /** The layers this snapshot was built from, most trusted first. */ - readonly layers: readonly EnvironmentLayer[] } /** @@ -121,12 +112,6 @@ export function createEnvironmentSnapshot(layers: readonly EnvironmentLayerInput return { get: name => getFrom(name, ENVIRONMENT_SOURCES), getFrom, - layers: ENVIRONMENT_SOURCES - .filter(source => bySource.has(source)) - .map((source): EnvironmentLayer => { - const path = bySource.get(source)?.path - return { source, ...path === undefined ? {} : { path } } - }), } } diff --git a/packages/util/environment/tests/environment.spec.ts b/packages/util/environment/tests/environment.spec.ts index 27c7b16e55..7083c9891d 100644 --- a/packages/util/environment/tests/environment.spec.ts +++ b/packages/util/environment/tests/environment.spec.ts @@ -28,15 +28,6 @@ describe('createEnvironmentSnapshot', () => { expect(layered.getFrom('SHARED', [])).toBeUndefined() }) - it('lists its layers in trust order with their paths', () => { - expect(layered.layers).toEqual([ - { source: 'process' }, - { source: 'project-env', path: '/work/.env' }, - { source: 'user-env', path: '/home/.dsh/.env' }, - ]) - expect(createEnvironmentSnapshot([{ source: 'process', values: {} }]).layers).toEqual([{ source: 'process' }]) - }) - it('copies each layer, so a later mutation of the source object cannot change it', () => { const values: Record = { KEY: 'first' } const snapshot = createEnvironmentSnapshot([{ source: 'process', values }]) @@ -76,7 +67,6 @@ describe('environmentOf', () => { // A host that discovered no files has exactly one layer, so the trusted // lookups every consumer makes still find what it was launched with. expect(snapshot.getFrom('DSH_ENV_SPEC_FALLBACK', ['process', 'user-env'])?.value).toBe('ambient') - expect(snapshot.layers).toEqual([{ source: 'process' }]) } finally { vi.unstubAllEnvs() } From d0e052dd83e6ffd8b5b21577a84fb46ea1ac0412 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 7 Aug 2026 23:33:11 +0800 Subject: [PATCH 23/27] cleanup(environment): keep one lookup order --- packages/llm/llm-deepseek/src/index.ts | 4 ++-- packages/llm/llm-pi-ai/src/index.ts | 2 +- packages/util/environment/README.i18n.yaml | 4 ++-- packages/util/environment/README.md | 4 ++-- packages/util/environment/README.zh.md | 4 ++-- packages/util/environment/src/index.ts | 13 +++++++------ .../util/environment/tests/environment.spec.ts | 15 +++++---------- packages/web/web-search-deepseek/src/index.ts | 4 ++-- packages/web/web-search-exa/src/index.ts | 2 +- packages/web/web-search-perplexity/src/index.ts | 2 +- 10 files changed, 25 insertions(+), 29 deletions(-) diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index c2a9360f64..6d052edc45 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -182,7 +182,7 @@ export function resolveAdapterOptions(config: Config, environment?: EnvironmentS return { apiKeyEnv: credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV), baseURL: config.baseURL - ?? environment?.getFrom(BASE_URL_ENV, ['process', 'project-env', 'user-env'])?.value + ?? environment?.get(BASE_URL_ENV)?.value ?? PUBLIC_BASE_URL, defaults: { thinking: config.thinking, @@ -232,7 +232,7 @@ export function apply(ctx: Context, config: Config): void { } else { // Without the seam there is no managed store to rank against, so the // environment is the whole credential plane. - const ambient = environmentOf(ctx).getFrom(ref, ['process', 'project-env', 'user-env']) + const ambient = environmentOf(ctx).get(ref) if (ambient !== undefined && ambient.value.length > 0) { return assertUsableApiKey(ambient.value, 'llm-deepseek', ref) } diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index 2f9b50a717..42102507c0 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -143,7 +143,7 @@ export function apply(ctx: Context, config: Config): void { const hit = credentials !== undefined ? (await credentials.resolve(ref))?.value // Without the seam the environment is the whole credential plane. - : environmentOf(ctx).getFrom(ref, ['process', 'project-env', 'user-env'])?.value + : environmentOf(ctx).get(ref)?.value if (hit !== undefined && hit.length > 0) return assertUsableApiKey(hit, 'llm-pi-ai', ref) throw new LlmError( `llm-pi-ai: no credential for provider route "${provider}"; its profile resolves ${ref}, which is not` diff --git a/packages/util/environment/README.i18n.yaml b/packages/util/environment/README.i18n.yaml index ea1e025257..ecd2fca34b 100644 --- a/packages/util/environment/README.i18n.yaml +++ b/packages/util/environment/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/environment/README.md -README.md: 1bb444bc217ce1a01fb98f954d6e1c2bbc3db957 -README.zh.md: a46adf0beeb0fb2069e198c99e4c00c2e8c09c6c +README.md: 1df857851f0f0a5a5ac52365c5e563a1c001bfca +README.zh.md: 41ea1af3a6eb95d58456f43e0f7f0a90e4fe7ac6 diff --git a/packages/util/environment/README.md b/packages/util/environment/README.md index 1bb444bc21..1df857851f 100644 --- a/packages/util/environment/README.md +++ b/packages/util/environment/README.md @@ -14,7 +14,7 @@ Values do also reach `process.env` — a user's `--config` tree and third-party ## Resolving -`get(name)` searches every layer, most trusted first. `getFrom(name, sources)` searches only the layers the caller trusts. +`get(name)` searches every layer, most trusted first. `getFrom(name, sources)` searches only the named layers without changing that trust order. **Omitting a layer is a refusal, not a demotion** — a caller that must never accept a layer leaves it out of the list, so no future reordering can let it back in. The provider adapters name all three, because the product trusts the project it runs in; the mechanism exists for the decisions where that is not true. @@ -25,7 +25,7 @@ import type { Context } from 'cordis' import { environmentOf } from '@deepseek-ai/dsh-environment' declare const ctx: Context -const endpoint = environmentOf(ctx).getFrom('DEEPSEEK_BASE_URL', ['process', 'project-env', 'user-env'])?.value +const endpoint = environmentOf(ctx).get('DEEPSEEK_BASE_URL')?.value ``` `environmentOf(ctx)` returns the launcher's snapshot when the product CLI booted the tree, and otherwise the inherited environment as the only layer. That fallback does not weaken the rules: an SDK host or a bare `cordis.yml` discovered no files, so everything it has really is the environment it was launched with. diff --git a/packages/util/environment/README.zh.md b/packages/util/environment/README.zh.md index a46adf0bee..41ea1af3a6 100644 --- a/packages/util/environment/README.zh.md +++ b/packages/util/environment/README.zh.md @@ -14,7 +14,7 @@ ## 解析 -`get(name)` 按可信度从高到低搜索所有层。`getFrom(name, sources)` 只搜索调用方信任的层。 +`get(name)` 按可信度从高到低搜索所有层。`getFrom(name, sources)` 只搜索指定的层,不改变这一可信顺序。 **省略某一层是拒绝,不是降级**——绝不能接受某一层的调用方直接不把它列进去,后续任何重新排序都无法让它回来。provider 适配器三层全列,因为产品信任它所运行的项目;该机制是为那些「并非如此」的决策准备的。 @@ -25,7 +25,7 @@ import type { Context } from 'cordis' import { environmentOf } from '@deepseek-ai/dsh-environment' declare const ctx: Context -const endpoint = environmentOf(ctx).getFrom('DEEPSEEK_BASE_URL', ['process', 'project-env', 'user-env'])?.value +const endpoint = environmentOf(ctx).get('DEEPSEEK_BASE_URL')?.value ``` 当产品 CLI(命令行界面)启动了这棵树时,`environmentOf(ctx)` 返回启动器的快照;否则返回只含继承环境的那一层。该回退并不削弱规则:SDK 宿主或裸 `cordis.yml` 从未发现过任何文件,因此它拥有的一切确实就是它被启动时的环境。 diff --git a/packages/util/environment/src/index.ts b/packages/util/environment/src/index.ts index 6f051603e6..a86689e809 100644 --- a/packages/util/environment/src/index.ts +++ b/packages/util/environment/src/index.ts @@ -22,8 +22,8 @@ import type { Context } from 'cordis' */ export type EnvironmentSource = 'process' | 'project-env' | 'user-env' -/** Layer order, most trusted first — the default search order of {@link EnvironmentSnapshot.get}. */ -export const ENVIRONMENT_SOURCES: readonly EnvironmentSource[] = ['process', 'project-env', 'user-env'] +/** Layer order, most trusted first. */ +const SOURCE_ORDER: readonly EnvironmentSource[] = ['process', 'project-env', 'user-env'] /** One resolved variable and the layer it came from. */ export interface EnvironmentEntry { @@ -54,7 +54,7 @@ export interface EnvironmentSnapshot { * that must never come from a project directory omits `project-env` so no * ordering change can let it back in. * @param name - the variable name. - * @param sources - the layers to search, in the caller's own priority order. + * @param sources - the layers allowed in the canonical trust order. * @returns the first matching entry, or `undefined`. */ getFrom(name: string, sources: readonly EnvironmentSource[]): EnvironmentEntry | undefined @@ -81,7 +81,7 @@ export interface EnvironmentLayerInput { /** * Build the snapshot from each layer's contents. - * @param layers - the layers in any order; the result searches them by {@link ENVIRONMENT_SOURCES}. + * @param layers - the layers in any order; the result searches them by canonical trust order. * @returns the immutable snapshot. */ export function createEnvironmentSnapshot(layers: readonly EnvironmentLayerInput[]): EnvironmentSnapshot { @@ -101,7 +101,8 @@ export function createEnvironmentSnapshot(layers: readonly EnvironmentLayerInput } const getFrom = (name: string, sources: readonly EnvironmentSource[]): EnvironmentEntry | undefined => { const key = lookupKey(name) - for (const source of sources) { + for (const source of SOURCE_ORDER) { + if (!sources.includes(source)) continue const layer = bySource.get(source) const value = layer?.values.get(key) if (value === undefined) continue @@ -110,7 +111,7 @@ export function createEnvironmentSnapshot(layers: readonly EnvironmentLayerInput return undefined } return { - get: name => getFrom(name, ENVIRONMENT_SOURCES), + get: name => getFrom(name, SOURCE_ORDER), getFrom, } } diff --git a/packages/util/environment/tests/environment.spec.ts b/packages/util/environment/tests/environment.spec.ts index 7083c9891d..8ed3823832 100644 --- a/packages/util/environment/tests/environment.spec.ts +++ b/packages/util/environment/tests/environment.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import { - createEnvironmentSnapshot, DSH_ENVIRONMENT_KEY, ENVIRONMENT_SOURCES, environmentOf, isBootstrapOnly, + createEnvironmentSnapshot, DSH_ENVIRONMENT_KEY, environmentOf, isBootstrapOnly, } from '../src/index.ts' const layered = createEnvironmentSnapshot([ @@ -18,13 +18,12 @@ describe('createEnvironmentSnapshot', () => { expect(layered.get('ABSENT')).toBeUndefined() }) - it('treats an omitted layer as invisible, not merely lower', () => { + it('filters layers without changing their trust order', () => { // The point of getFrom: a routing field that must never come from a // project directory cannot be reached by reordering, only by listing it. expect(layered.getFrom('ONLY_PROJECT', ['process', 'user-env'])).toBeUndefined() - expect(layered.getFrom('SHARED', ['user-env', 'process'])).toEqual({ - value: 'from-user', source: 'user-env', path: '/home/.dsh/.env', - }) + expect(layered.getFrom('SHARED', ['user-env', 'process'])) + .toEqual({ value: 'from-process', source: 'process' }) expect(layered.getFrom('SHARED', [])).toBeUndefined() }) @@ -42,12 +41,11 @@ describe('createEnvironmentSnapshot', () => { expect(snapshot.get('EMPTY')).toEqual({ value: '', source: 'process' }) }) - it('orders lookups by ENVIRONMENT_SOURCES regardless of construction order', () => { + it('orders lookups canonically regardless of construction order', () => { const reversed = createEnvironmentSnapshot([ { source: 'user-env', path: '/u', values: { K: 'u' } }, { source: 'process', values: { K: 'p' } }, ]) - expect(ENVIRONMENT_SOURCES).toEqual(['process', 'project-env', 'user-env']) expect(reversed.get('K')).toEqual({ value: 'p', source: 'process' }) }) }) @@ -64,9 +62,6 @@ describe('environmentOf', () => { try { const snapshot = environmentOf(new Context()) expect(snapshot.get('DSH_ENV_SPEC_FALLBACK')).toEqual({ value: 'ambient', source: 'process' }) - // A host that discovered no files has exactly one layer, so the trusted - // lookups every consumer makes still find what it was launched with. - expect(snapshot.getFrom('DSH_ENV_SPEC_FALLBACK', ['process', 'user-env'])?.value).toBe('ambient') } finally { vi.unstubAllEnvs() } diff --git a/packages/web/web-search-deepseek/src/index.ts b/packages/web/web-search-deepseek/src/index.ts index 60b5a64692..5e55e12457 100644 --- a/packages/web/web-search-deepseek/src/index.ts +++ b/packages/web/web-search-deepseek/src/index.ts @@ -90,12 +90,12 @@ export function apply(ctx: Context, config: Config): void { const credentials = ctx.get('credentials') if (credentials !== undefined) return (await credentials.resolve(apiKeyEnv))?.value // Without the seam the environment is the whole credential plane. - const ambient = environmentOf(ctx).getFrom(apiKeyEnv, ['process', 'project-env', 'user-env']) + const ambient = environmentOf(ctx).get(apiKeyEnv) return ambient !== undefined && ambient.value.length > 0 ? ambient.value : undefined }, apiKeyEnv, baseURL: config.baseURL - ?? environmentOf(ctx).getFrom(SEARCH_BASE_URL_ENV, ['process', 'project-env', 'user-env'])?.value + ?? environmentOf(ctx).get(SEARCH_BASE_URL_ENV)?.value ?? DEEPSEEK_DEFAULT_BASE_URL, model: config.model ?? DEEPSEEK_DEFAULT_MODEL, apiVersion: config.apiVersion ?? DEEPSEEK_DEFAULT_API_VERSION, diff --git a/packages/web/web-search-exa/src/index.ts b/packages/web/web-search-exa/src/index.ts index d5c8b938ac..2ecb71336a 100644 --- a/packages/web/web-search-exa/src/index.ts +++ b/packages/web/web-search-exa/src/index.ts @@ -61,7 +61,7 @@ export function apply(ctx: Context, config: Config): void { ctx.web.registerSearchProvider(new ExaSearchProvider({ // Every environment layer may name this key: the product trusts the // project it is launched in, and the managed store is not involved here. - apiKey: config.apiKey ?? environmentOf(ctx).getFrom('EXA_API_KEY', ['process', 'project-env', 'user-env'])?.value ?? '', + apiKey: config.apiKey ?? environmentOf(ctx).get('EXA_API_KEY')?.value ?? '', baseURL: config.baseURL ?? EXA_DEFAULT_BASE_URL, searchType: config.searchType ?? EXA_DEFAULT_SEARCH_TYPE, highlightsPerResult: config.highlightsPerResult ?? EXA_DEFAULT_HIGHLIGHTS_PER_RESULT, diff --git a/packages/web/web-search-perplexity/src/index.ts b/packages/web/web-search-perplexity/src/index.ts index c8088a3c23..e1fe6a2606 100644 --- a/packages/web/web-search-perplexity/src/index.ts +++ b/packages/web/web-search-perplexity/src/index.ts @@ -55,7 +55,7 @@ export function apply(ctx: Context, config: Config): void { ctx.web.registerSearchProvider(new PerplexitySearchProvider({ // Every environment layer may name this key: the product trusts the // project it is launched in, and the managed store is not involved here. - apiKey: config.apiKey ?? environmentOf(ctx).getFrom('PERPLEXITY_API_KEY', ['process', 'project-env', 'user-env'])?.value ?? '', + apiKey: config.apiKey ?? environmentOf(ctx).get('PERPLEXITY_API_KEY')?.value ?? '', baseURL: config.baseURL ?? PERPLEXITY_DEFAULT_BASE_URL, model: config.model ?? PERPLEXITY_DEFAULT_MODEL, maxTokens: config.maxTokens ?? PERPLEXITY_DEFAULT_MAX_TOKENS, From b5fbcaccf8b1d4ec5ad334a4111b760ae17dcfa0 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 7 Aug 2026 23:37:53 +0800 Subject: [PATCH 24/27] cleanup(config): localize bootstrap policy to app boot --- ...4-configuration-source-ownership.i18n.yaml | 4 +- ...26-08-04-configuration-source-ownership.md | 2 +- ...08-04-configuration-source-ownership.zh.md | 2 +- packages/ui/app-boot/README.i18n.yaml | 4 +- packages/ui/app-boot/README.md | 2 +- packages/ui/app-boot/README.zh.md | 2 +- packages/ui/app-boot/src/index.ts | 68 ++++++++++++++++++- packages/util/environment/README.i18n.yaml | 4 +- packages/util/environment/README.md | 12 +--- packages/util/environment/README.zh.md | 12 +--- packages/util/environment/src/index.ts | 66 ------------------ .../environment/tests/environment.spec.ts | 36 +--------- 12 files changed, 80 insertions(+), 134 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml index 2d966fa8ea..22935b42ca 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.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 .agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md -2026-08-04-configuration-source-ownership.md: 0b11df50c8f00875a92b722e9f225dd27ed218b5 -2026-08-04-configuration-source-ownership.zh.md: 648cea0167bef564195597f7b2791b5211d40267 +2026-08-04-configuration-source-ownership.md: ef30a22c120af1437f348e52843e1dd45c9837ae +2026-08-04-configuration-source-ownership.zh.md: c1567c6e823a0bc8ed8d1f9ed50a11a5d204ede8 diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md index 0b11df50c8..ef30a22c12 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md @@ -42,7 +42,7 @@ The launching environment wins because `DEEPSEEK_API_KEY=… dsh`, a CI secret, **The project the harness is launched in is trusted, by default and without a prompt.** A checkout may carry its own endpoint, its own ordinary variables, and its own key; the key ranks below the managed store, so a key stored through the Models page is never displaced by one a checkout happens to contain. `EnvironmentSnapshot.getFrom(name, sources)` still searches only the layers a caller names, and omitting one is a refusal rather than a demotion — the mechanism exists for the decisions where a layer must be unreachable, not because the project is one of them today. -**Trust does not extend to changing the harness itself.** `isBootstrapOnly` rejects, at load and before anything is materialized, any `.env` that sets a variable governing how a process launches (`PATH`, `SHELL`, `NODE_OPTIONS`, `LD_PRELOAD`), what code a runtime executes before the program it was asked to run (`BASH_ENV`, `PERL5OPT`, `PYTHONSTARTUP`, `RUBYOPT`, `JAVA_TOOL_OPTIONS`, the Git hook commands), where model-visible instructions load from (the whole `DSH_*` namespace, `HOME`, `XDG_*`), or how the network is reached and trusted (proxy and CA variables). Matching is case-insensitive, so `https_proxy` is not a bypass. +**Trust does not extend to changing the harness itself.** `loadLayeredEnv` rejects, at load and before anything is materialized, any `.env` that sets a variable governing how a process launches (`PATH`, `SHELL`, `NODE_OPTIONS`, `LD_PRELOAD`), what code a runtime executes before the program it was asked to run (`BASH_ENV`, `PERL5OPT`, `PYTHONSTARTUP`, `RUBYOPT`, `JAVA_TOOL_OPTIONS`, the Git hook commands), where model-visible instructions load from (the whole `DSH_*` namespace, `HOME`, `XDG_*`), or how the network is reached and trusted (proxy and CA variables). Matching is case-insensitive, so `https_proxy` is not a bypass. The line is that these take effect with no user action, before any turn, outside the permission policy and the sandbox. `DSH_PERMISSION_MODE` would switch off the approvals that make trusting a project meaningful at all, and `BASH_ENV` runs a file of the project's choosing on every single `bash -c` the bash tool issues — the project's code running under the agent's policy is the deal; the project rewriting that policy is not. Enumerating these is a losing game one variable at a time, which is why the whole `DSH_*` namespace is denied rather than an audited subset, and why the list is organised by what a variable *does* rather than by which runtime owns it. There is no opt-out: an escape hatch would have to be readable from somewhere, and anything a discovered file could set is the hole itself. diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md index 648cea0167..c1567c6e82 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md @@ -44,7 +44,7 @@ inherited process environment (read-only, wins) **harness 被启动于其中的项目默认可信,且不做询问。** 一个 checkout 可以携带自己的 endpoint、自己的普通变量和自己的密钥;密钥排在受管存储之下,因此通过 Models 页存下的密钥绝不会被 checkout 中恰好带有的那一个顶掉。`EnvironmentSnapshot.getFrom(name, sources)` 仍然只搜索调用方点名的层,省略某层仍是拒绝而不是降级——该机制是为「某一层必须不可达」的那些决策准备的,而项目层今天不在其列。 -**信任不延伸到改变 harness 本身。** `isBootstrapOnly` 会在加载时、且在物化任何内容之前,拒绝任何设置了下列变量的 `.env`:决定进程如何启动的(`PATH`、`SHELL`、`NODE_OPTIONS`、`LD_PRELOAD`)、决定运行时在执行被要求运行的程序之前先执行哪些代码的(`BASH_ENV`、`PERL5OPT`、`PYTHONSTARTUP`、`RUBYOPT`、`JAVA_TOOL_OPTIONS`、Git 的钩子命令)、决定模型可见指令从哪里加载的(整个 `DSH_*` 命名空间、`HOME`、`XDG_*`),以及决定网络如何抵达与信任的(proxy 与 CA 变量)。匹配不区分大小写,因此 `https_proxy` 不是绕过手段。 +**信任不延伸到改变 harness 本身。** `loadLayeredEnv` 会在加载时、且在物化任何内容之前,拒绝任何设置了下列变量的 `.env`:决定进程如何启动的(`PATH`、`SHELL`、`NODE_OPTIONS`、`LD_PRELOAD`)、决定运行时在执行被要求运行的程序之前先执行哪些代码的(`BASH_ENV`、`PERL5OPT`、`PYTHONSTARTUP`、`RUBYOPT`、`JAVA_TOOL_OPTIONS`、Git 的钩子命令)、决定模型可见指令从哪里加载的(整个 `DSH_*` 命名空间、`HOME`、`XDG_*`),以及决定网络如何抵达与信任的(proxy 与 CA 变量)。匹配不区分大小写,因此 `https_proxy` 不是绕过手段。 这条界线在于:它们无需任何用户动作、在任何一轮开始之前、且在权限策略与沙箱之外就生效。`DSH_PERMISSION_MODE` 会关掉让「信任项目」根本成立的那道审批,而 `BASH_ENV` 会在 bash 工具发出的每一次 `bash -c` 上执行项目指定的文件——项目的代码在 agent 的策略下运行是约定,项目改写那份策略不是。一个变量一个变量地枚举是必输的游戏,所以整个 `DSH_*` 命名空间被拒绝而不是只拒绝一份经审查的子集,也所以这份清单是按变量*做什么*而不是按哪个运行时拥有它来组织的。不设逃生门:逃生门本身总得从某处读取,而任何被发现的文件能设置的东西,就是那个漏洞本身。 diff --git a/packages/ui/app-boot/README.i18n.yaml b/packages/ui/app-boot/README.i18n.yaml index 1c15f51109..59426a9041 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: 9c2f9a8dac6b164cb23260e743eb2cdf1f29d3aa -README.zh.md: 8422a176e682a87d1e592d5140b719e628e7d8e7 +README.md: 25e0c10932a2bede7f6c6582043af436b6153f4a +README.zh.md: 649a60802660fdd8d4a6cd85dc64b5a65cee5a88 diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index 9c2f9a8dac..25e0c10932 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -37,7 +37,7 @@ A profile is a directory under `$DSH_HOME/profiles/` (the Harness home res User-level machine-local preferences also live in the Harness home: -- **`.env`** — the product CLI's ordinary environment layers: the invoking directory's file outranks the Harness-home file, and both sit below the inherited environment. `loadLayeredEnv` snapshots each value's source, rejects bootstrap-only file variables, and materializes accepted values into `process.env` for Loader expressions and third-party libraries. Managed credentials live separately in [`.credentials.yaml`](../../credentials/credentials-local/README.md); a credential left in either `.env` remains a lower-priority fallback. +- **`.env`** — the product CLI's ordinary environment layers: the invoking directory's file outranks the Harness-home file, and both sit below the inherited environment. `loadLayeredEnv` snapshots each value's source, rejects case-insensitive bootstrap-only process/module/runtime/Git/network variables and the `DSH_`/`XDG_`/`DYLD_`/`BASH_FUNC_` namespaces, and materializes accepted values into `process.env` for Loader expressions and third-party libraries. Managed credentials live separately in [`.credentials.yaml`](../../credentials/credentials-local/README.md); a credential left in either `.env` remains a lower-priority fallback. - **`cordis.patch.yml`** (home level) and **`profiles//cordis.patch.yml`** — the user patch layers, applied after every bundle layer (per-profile first, then the home-level file, which therefore outranks it): 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 composed tree is a stderr warning. An empty or comments-only file throws (it parses to nothing, not to a list); disable the layer with `[]`. Long-lived surfaces keep `cordis.patch.yml` live through `watchUserPatches`; one-shot runs read only the startup value. The watcher targets the exact path even when the file or immediate parent does not exist, serializes bursts, and recomposes the user patches inside the caller's layer order (bundle layers below, overlay/flag patches above). A rejected read, parse, or Loader candidate leaves the last good tree running and the HMR service broadcasts `hmr/config-update-failed(filename, Error)` after logging it; observer failures are contained. Disposing the context closes the watcher and drains an active refresh. diff --git a/packages/ui/app-boot/README.zh.md b/packages/ui/app-boot/README.zh.md index 8422a176e6..649a608026 100644 --- a/packages/ui/app-boot/README.zh.md +++ b/packages/ui/app-boot/README.zh.md @@ -37,7 +37,7 @@ profile 是位于 `$DSH_HOME/profiles/` 下的目录(Harness home 由 [` 用户级的机器本地偏好同样位于 Harness home 中: -- **`.env`**:产品 CLI 的普通环境层;调用目录的文件优先于 Harness home 的文件,两者都低于继承环境。`loadLayeredEnv` 记录每个值的来源,拒绝文件中的 bootstrap-only 变量,并把其余值物化进 `process.env`,供 Loader 表达式和第三方库使用。受管凭据另存于 [`.credentials.yaml`](../../credentials/credentials-local/README.md);留在任一 `.env` 中的凭据仍是低优先级后备值。 +- **`.env`**:产品 CLI 的普通环境层;调用目录的文件优先于 Harness home 的文件,两者都低于继承环境。`loadLayeredEnv` 记录每个值的来源,按不区分大小写的方式拒绝文件中的 bootstrap-only 进程、模块、运行时、Git 与网络变量,以及整个 `DSH_`/`XDG_`/`DYLD_`/`BASH_FUNC_` 命名空间,并把其余值物化进 `process.env`,供 Loader 表达式和第三方库使用。受管凭据另存于 [`.credentials.yaml`](../../credentials/credentials-local/README.md);留在任一 `.env` 中的凭据仍是低优先级后备值。 - **`cordis.patch.yml`**(home 级)与 **`profiles//cordis.patch.yml`**:用户 patch 层,应用在所有组合包层之后(先应用逐 profile 的文件,再应用 home 级文件,因此后者优先级更高):按 id 定位的 patch 会替换对应条目的整个 `config`(未改字段也要重述),`insert` 会添加条目,`!!js` 表达式则在挂载时插值。如果 patch 指定的条目 id 不在组合后的树中,则输出一条 stderr 警告。空文件或仅含注释的文件会抛出异常(其解析结果为空,而不是列表);如需禁用该层,请使用 `[]`。 长期运行的 surface 会持续应用 `cordis.patch.yml` 的变更,具体由 `watchUserPatches` 负责;一次性运行只读取启动时的值。即使该文件或其直接父目录不存在,watcher 仍会监视确切路径;它会串行处理突发变更,并按调用方的层次顺序重新组合用户 patch(组合包层在下、overlay/标志 patch 在上)。读取失败、解析失败或 Loader 候选被拒时,最后一个可用树会继续运行;HMR 服务记录错误后广播 `hmr/config-update-failed(filename, Error)`,并隔离 observer 失败。上下文 dispose 时会关闭 watcher,并等待进行中的刷新结束。 diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index 240da02698..34b09949c4 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -15,7 +15,7 @@ import { Context, type FiberState } from 'cordis' import Loader, { type Entry, type EntryOptions } from '@cordisjs/plugin-loader' import Include, { applyEntryPatches, entryListSchema, type PatchOptions } from '@cordisjs/plugin-include' import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-paths' -import { createEnvironmentSnapshot, isBootstrapOnly, type EnvironmentSnapshot } from '@deepseek-ai/dsh-environment' +import { createEnvironmentSnapshot, type EnvironmentSnapshot } from '@deepseek-ai/dsh-environment' import type {} from '@cordisjs/plugin-hmr' // Side-effect type import: resolves `ctx.get('systemPrompt')` to the service. import type {} from '@deepseek-ai/dsh-system-prompt' @@ -88,6 +88,72 @@ export function loadEnv( } } +/** Exact names no discovered file may set. */ +const BOOTSTRAP_NAMES = new Set([ + // Process launch and module resolution. + 'PATH', 'HOME', 'USERPROFILE', 'SHELL', + 'NODE_OPTIONS', 'NODE_PATH', 'NODE_EXTRA_CA_CERTS', + 'LD_PRELOAD', 'LD_LIBRARY_PATH', 'LD_AUDIT', + // Interpreter start-up hooks: each of these makes a runtime execute a file + // of the setter's choosing on every invocation, before the program runs. + // `BASH_ENV` is the sharpest — the bash tool spawns `bash -c`, which sources + // it every time — but every runtime an agent shells out to has one. + 'BASH_ENV', 'ENV', 'SHELLOPTS', 'BASHOPTS', + 'PERL5OPT', 'PERL5LIB', 'PYTHONSTARTUP', 'PYTHONPATH', 'RUBYOPT', 'RUBYLIB', + 'JAVA_TOOL_OPTIONS', '_JAVA_OPTIONS', 'JDK_JAVA_OPTIONS', + 'PYTHONHOME', + // Version-control hooks that run a command on the setter's behalf, and the + // config redirections that can define such a hook indirectly (a substituted + // git config file can set core.pager or a credential helper). + 'GIT_SSH', 'GIT_SSH_COMMAND', 'GIT_EXTERNAL_DIFF', 'GIT_PAGER', 'GIT_EDITOR', + 'GIT_ASKPASS', 'SSH_ASKPASS', + 'GIT_CONFIG_GLOBAL', 'GIT_CONFIG_SYSTEM', 'GIT_CONFIG_COUNT', + 'EDITOR', 'VISUAL', 'PAGER', + // Network reach and trust. + 'SSL_CERT_FILE', 'SSL_CERT_DIR', + 'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY', + 'REQUESTS_CA_BUNDLE', 'CURL_CA_BUNDLE', + // Turns off TLS verification outright, which is the sharpest form of + // "how the network is trusted". + 'NODE_TLS_REJECT_UNAUTHORIZED', +]) + +/** Name prefixes no discovered file may set. */ +const BOOTSTRAP_PREFIXES = ['DSH_', 'XDG_', 'DYLD_', 'BASH_FUNC_'] + +/** + * Whether a variable may come only from the inherited process environment. + * + * The invoking project is trusted to *configure* the agent's work — its + * endpoints, its ordinary variables, even a credential. It is not trusted to + * change the harness itself, and that is what a bootstrap variable does: it + * decides how a process launches (`PATH`, `NODE_OPTIONS`, `LD_PRELOAD`), what + * code a runtime executes before the program it was asked to run (`BASH_ENV` + * and its per-language siblings, the Git hook commands), where model-visible + * instructions load from (`DSH_*` covers the Harness home, the agents home, + * and the bundled skill root), or how the network is reached and trusted + * (proxy and CA variables). + * + * The distinction is that these take effect with no user action, before any + * turn, outside the permission policy and the sandbox — `DSH_PERMISSION_MODE` + * would switch off the approvals that make trusting a project meaningful at + * all, and `BASH_ENV` runs a file of the project's choosing on every single + * `bash -c` the tool issues. Trusting a project's code to run under the + * agent's policy is not the same as letting it rewrite that policy. + * + * They are therefore rejected at load rather than ranked below another layer: + * a user who wrote one into a file believes it applies, and silently ignoring + * it is its own failure. The whole `DSH_*` namespace is denied rather than an + * audited subset, because a switch added later must not become settable by + * being forgotten. + * @param name - the variable name. + * @returns true when only the inherited environment may supply it. + */ +function isBootstrapOnly(name: string): boolean { + const upper = name.toUpperCase() + return BOOTSTRAP_NAMES.has(upper) || BOOTSTRAP_PREFIXES.some(prefix => upper.startsWith(prefix)) +} + /** * Parse one directory's `.env` without applying it, rejecting any bootstrap * variable it declares. A discovered file must not decide how this process diff --git a/packages/util/environment/README.i18n.yaml b/packages/util/environment/README.i18n.yaml index ecd2fca34b..1c5f784bc4 100644 --- a/packages/util/environment/README.i18n.yaml +++ b/packages/util/environment/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/environment/README.md -README.md: 1df857851f0f0a5a5ac52365c5e563a1c001bfca -README.zh.md: 41ea1af3a6eb95d58456f43e0f7f0a90e4fe7ac6 +README.md: af6b0d9cc66b0bdfa1ad9ffb273260d0f4f06ddc +README.zh.md: 98c3c69ec96f835721e042960fe044fe075e6159 diff --git a/packages/util/environment/README.md b/packages/util/environment/README.md index 1df857851f..af6b0d9cc6 100644 --- a/packages/util/environment/README.md +++ b/packages/util/environment/README.md @@ -30,17 +30,7 @@ const endpoint = environmentOf(ctx).get('DEEPSEEK_BASE_URL')?.value `environmentOf(ctx)` returns the launcher's snapshot when the product CLI booted the tree, and otherwise the inherited environment as the only layer. That fallback does not weaken the rules: an SDK host or a bare `cordis.yml` discovered no files, so everything it has really is the environment it was launched with. -## Bootstrap variables - -`isBootstrapOnly(name)` names the variables only the inherited environment may set. The launcher rejects a `.env` that declares one, before applying anything. - -Trusting a project to configure the agent's work is not the same as letting it change the harness. A bootstrap variable decides **how a process launches** (`PATH`, `SHELL`, `NODE_OPTIONS`, `LD_PRELOAD`, `DYLD_*`), **what code a runtime executes before the program it was asked to run** (`BASH_ENV` and its per-language siblings — `PERL5OPT`, `PYTHONSTARTUP`, `RUBYOPT`, `JAVA_TOOL_OPTIONS` — plus the Git hook commands), **where model-visible instructions load from** (the whole `DSH_*` namespace, `HOME`, `XDG_*`), or **how the network is reached and trusted** (proxy and CA variables). Matching is case-insensitive, so `https_proxy` is not a bypass. - -These take effect with no user action, before any turn, outside the permission policy and the sandbox: `DSH_PERMISSION_MODE` would switch off the approvals that make trusting a project meaningful, and `BASH_ENV` runs a file of the project's choosing on every `bash -c` the bash tool issues. - -The whole `DSH_*` namespace is denied rather than an audited subset: the harness's own switches — the permission mode, the agents home, the bundled skill root — are exactly what a hostile project would want, and a switch added later must not become settable by forgetting to list it. - ## Known Limitations and Deferred Work -- **The snapshot is not a subprocess boundary** — every layer is also materialized into `process.env`, so ordinary project variables reach child processes under [`dsh-subprocess`](../../subprocess/subprocess/README.md)'s scrub. That is intended for ordinary variables; the code-loading hooks that would abuse it are rejected at load instead, and the deny list is the thing to extend when a new runtime hook appears. +- **The snapshot is not a subprocess boundary** — every layer is also materialized into `process.env`, so ordinary project variables reach child processes under [`dsh-subprocess`](../../subprocess/subprocess/README.md)'s scrub. The product launcher's [`.env` contract](../../ui/app-boot/README.md#profiles) rejects bootstrap variables before materialization. - **No per-workspace layer** — the project layer is the *invoking* directory, fixed at launch. A workspace selected later in the Web UI contributes nothing, deliberately: following it would let a model's own workspace change the harness environment mid-session. diff --git a/packages/util/environment/README.zh.md b/packages/util/environment/README.zh.md index 41ea1af3a6..98c3c69ec9 100644 --- a/packages/util/environment/README.zh.md +++ b/packages/util/environment/README.zh.md @@ -30,17 +30,7 @@ const endpoint = environmentOf(ctx).get('DEEPSEEK_BASE_URL')?.value 当产品 CLI(命令行界面)启动了这棵树时,`environmentOf(ctx)` 返回启动器的快照;否则返回只含继承环境的那一层。该回退并不削弱规则:SDK 宿主或裸 `cordis.yml` 从未发现过任何文件,因此它拥有的一切确实就是它被启动时的环境。 -## bootstrap 变量 - -`isBootstrapOnly(name)` 给出只有继承环境才能设置的变量。启动器一旦发现某个 `.env` 声明了其中之一,就会在应用任何内容之前拒绝启动。 - -信任一个项目配置 agent 的工作,不等于让它改变 harness 本身。bootstrap 变量决定**进程如何启动**(`PATH`、`SHELL`、`NODE_OPTIONS`、`LD_PRELOAD`、`DYLD_*`)、**运行时在执行被要求运行的程序之前先执行哪些代码**(`BASH_ENV` 及其各语言同类——`PERL5OPT`、`PYTHONSTARTUP`、`RUBYOPT`、`JAVA_TOOL_OPTIONS`——以及 Git 的钩子命令)、**模型可见的指令从哪里加载**(整个 `DSH_*` 命名空间、`HOME`、`XDG_*`),或者**网络如何抵达与信任**(proxy 与 CA 变量)。匹配不区分大小写,因此 `https_proxy` 不是绕过手段。 - -这些变量无需任何用户动作、在任何一轮开始之前、且在权限策略与沙箱之外就生效:`DSH_PERMISSION_MODE` 会关掉让「信任项目」有意义的那道审批,而 `BASH_ENV` 会在 bash 工具发出的每一次 `bash -c` 上执行项目指定的文件。 - -整个 `DSH_*` 命名空间被拒绝,而不是只拒绝一份经过审查的子集:harness 自己的开关——权限模式、agents home、内置 skill(技能)根目录——恰恰是敌意项目最想要的,而后来新增的开关不能因为忘记登记就变得可设置。 - ## Known Limitations and Deferred Work -- **快照不是子进程边界**:每一层同样会被物化进 `process.env`,因此项目里的普通变量会按 [`dsh-subprocess`](../../subprocess/subprocess/README.md) 的清洗规则抵达子进程。这对普通变量是有意为之;会滥用这一点的代码加载钩子改为在加载时拒绝,新的运行时钩子出现时该扩展的是那份拒绝清单。 +- **快照不是子进程边界**:每一层同样会被物化进 `process.env`,因此项目里的普通变量会按 [`dsh-subprocess`](../../subprocess/subprocess/README.md) 的清洗规则抵达子进程。产品启动器的 [`.env` 契约](../../ui/app-boot/README.md#profiles) 会在物化之前拒绝 bootstrap 变量。 - **没有按工作区划分的层**:项目层是*调用*目录,在启动时固定。之后在 Web UI 中选择的工作区不贡献任何内容,这是刻意的:跟随它等于让模型自己的工作区在会话中途改变 harness 的环境。 diff --git a/packages/util/environment/src/index.ts b/packages/util/environment/src/index.ts index a86689e809..939ddba633 100644 --- a/packages/util/environment/src/index.ts +++ b/packages/util/environment/src/index.ts @@ -143,69 +143,3 @@ declare module 'cordis' { launcherEnvironment?: EnvironmentSnapshot } } - -/** Exact names no discovered file may set. */ -const BOOTSTRAP_NAMES = new Set([ - // Process launch and module resolution. - 'PATH', 'HOME', 'USERPROFILE', 'SHELL', - 'NODE_OPTIONS', 'NODE_PATH', 'NODE_EXTRA_CA_CERTS', - 'LD_PRELOAD', 'LD_LIBRARY_PATH', 'LD_AUDIT', - // Interpreter start-up hooks: each of these makes a runtime execute a file - // of the setter's choosing on every invocation, before the program runs. - // `BASH_ENV` is the sharpest — the bash tool spawns `bash -c`, which sources - // it every time — but every runtime an agent shells out to has one. - 'BASH_ENV', 'ENV', 'SHELLOPTS', 'BASHOPTS', - 'PERL5OPT', 'PERL5LIB', 'PYTHONSTARTUP', 'PYTHONPATH', 'RUBYOPT', 'RUBYLIB', - 'JAVA_TOOL_OPTIONS', '_JAVA_OPTIONS', 'JDK_JAVA_OPTIONS', - 'PYTHONHOME', - // Version-control hooks that run a command on the setter's behalf, and the - // config redirections that can define such a hook indirectly (a substituted - // git config file can set core.pager or a credential helper). - 'GIT_SSH', 'GIT_SSH_COMMAND', 'GIT_EXTERNAL_DIFF', 'GIT_PAGER', 'GIT_EDITOR', - 'GIT_ASKPASS', 'SSH_ASKPASS', - 'GIT_CONFIG_GLOBAL', 'GIT_CONFIG_SYSTEM', 'GIT_CONFIG_COUNT', - 'EDITOR', 'VISUAL', 'PAGER', - // Network reach and trust. - 'SSL_CERT_FILE', 'SSL_CERT_DIR', - 'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY', - 'REQUESTS_CA_BUNDLE', 'CURL_CA_BUNDLE', - // Turns off TLS verification outright, which is the sharpest form of - // "how the network is trusted". - 'NODE_TLS_REJECT_UNAUTHORIZED', -]) - -/** Name prefixes no discovered file may set. */ -const BOOTSTRAP_PREFIXES = ['DSH_', 'XDG_', 'DYLD_', 'BASH_FUNC_'] - -/** - * Whether a variable may come only from the inherited process environment. - * - * The invoking project is trusted to *configure* the agent's work — its - * endpoints, its ordinary variables, even a credential. It is not trusted to - * change the harness itself, and that is what a bootstrap variable does: it - * decides how a process launches (`PATH`, `NODE_OPTIONS`, `LD_PRELOAD`), what - * code a runtime executes before the program it was asked to run (`BASH_ENV` - * and its per-language siblings, the Git hook commands), where model-visible - * instructions load from (`DSH_*` covers the Harness home, the agents home, - * and the bundled skill root), or how the network is reached and trusted - * (proxy and CA variables). - * - * The distinction is that these take effect with no user action, before any - * turn, outside the permission policy and the sandbox — `DSH_PERMISSION_MODE` - * would switch off the approvals that make trusting a project meaningful at - * all, and `BASH_ENV` runs a file of the project's choosing on every single - * `bash -c` the tool issues. Trusting a project's code to run under the - * agent's policy is not the same as letting it rewrite that policy. - * - * They are therefore rejected at load rather than ranked below another layer: - * a user who wrote one into a file believes it applies, and silently ignoring - * it is its own failure. The whole `DSH_*` namespace is denied rather than an - * audited subset, because a switch added later must not become settable by - * being forgotten. - * @param name - the variable name. - * @returns true when only the inherited environment may supply it. - */ -export function isBootstrapOnly(name: string): boolean { - const upper = name.toUpperCase() - return BOOTSTRAP_NAMES.has(upper) || BOOTSTRAP_PREFIXES.some(prefix => upper.startsWith(prefix)) -} diff --git a/packages/util/environment/tests/environment.spec.ts b/packages/util/environment/tests/environment.spec.ts index 8ed3823832..5951484a83 100644 --- a/packages/util/environment/tests/environment.spec.ts +++ b/packages/util/environment/tests/environment.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import { - createEnvironmentSnapshot, DSH_ENVIRONMENT_KEY, environmentOf, isBootstrapOnly, + createEnvironmentSnapshot, DSH_ENVIRONMENT_KEY, environmentOf, } from '../src/index.ts' const layered = createEnvironmentSnapshot([ @@ -67,37 +67,3 @@ describe('environmentOf', () => { } }) }) - -describe('isBootstrapOnly', () => { - it.each([ - 'PATH', 'HOME', 'USERPROFILE', 'SHELL', - 'NODE_OPTIONS', 'NODE_PATH', 'NODE_EXTRA_CA_CERTS', - 'LD_PRELOAD', 'LD_LIBRARY_PATH', - 'SSL_CERT_FILE', 'SSL_CERT_DIR', - 'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY', - ])('rejects %s, which decides how the process starts or reaches the network', (name) => { - expect(isBootstrapOnly(name)).toBe(true) - }) - - it.each([ - ['DSH_HOME', 'the harness home'], - ['DSH_PERMISSION_MODE', 'the permission mode'], - ['DSH_AGENTS_HOME', 'a model-visible instruction root'], - ['DSH_ANYTHING_ADDED_LATER', 'a switch that does not exist yet'], - ['XDG_CONFIG_HOME', 'a state root'], - ['DYLD_INSERT_LIBRARIES', 'a library preload'], - ])('rejects the whole namespace: %s (%s)', (name) => { - expect(isBootstrapOnly(name)).toBe(true) - }) - - it('matches case-insensitively, so a lowercase proxy name is not a bypass', () => { - expect(isBootstrapOnly('https_proxy')).toBe(true) - expect(isBootstrapOnly('dsh_permission_mode')).toBe(true) - }) - - it('allows ordinary variables, including provider credentials and endpoints', () => { - for (const name of ['DEEPSEEK_API_KEY', 'DEEPSEEK_BASE_URL', 'EXA_API_KEY', 'MY_PROJECT_FLAG', 'PATHS']) { - expect(isBootstrapOnly(name)).toBe(false) - } - }) -}) From 62ae990c27d7dd5bc4d78380aaa655e7949cfe56 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 7 Aug 2026 23:43:15 +0800 Subject: [PATCH 25/27] cleanup(config): consolidate source ownership rationale --- packages/ui/app-boot/README.i18n.yaml | 4 +- packages/ui/app-boot/README.md | 2 +- packages/ui/app-boot/README.zh.md | 2 +- packages/ui/app-boot/src/index.ts | 87 +++------------------ packages/ui/app-boot/tests/app-boot.spec.ts | 14 +--- packages/util/environment/src/index.ts | 41 +++------- scripts/verify-config-source-ownership.ts | 14 +--- 7 files changed, 30 insertions(+), 134 deletions(-) diff --git a/packages/ui/app-boot/README.i18n.yaml b/packages/ui/app-boot/README.i18n.yaml index 59426a9041..422e585575 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: 25e0c10932a2bede7f6c6582043af436b6153f4a -README.zh.md: 649a60802660fdd8d4a6cd85dc64b5a65cee5a88 +README.md: 359f05a83b41db6db5ede40db7317a0fb15de43b +README.zh.md: a916236e30b50cc884d9d5876f27fcb1aa6f0777 diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index 25e0c10932..359f05a83b 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -37,7 +37,7 @@ A profile is a directory under `$DSH_HOME/profiles/` (the Harness home res User-level machine-local preferences also live in the Harness home: -- **`.env`** — the product CLI's ordinary environment layers: the invoking directory's file outranks the Harness-home file, and both sit below the inherited environment. `loadLayeredEnv` snapshots each value's source, rejects case-insensitive bootstrap-only process/module/runtime/Git/network variables and the `DSH_`/`XDG_`/`DYLD_`/`BASH_FUNC_` namespaces, and materializes accepted values into `process.env` for Loader expressions and third-party libraries. Managed credentials live separately in [`.credentials.yaml`](../../credentials/credentials-local/README.md); a credential left in either `.env` remains a lower-priority fallback. +- **`.env`** — the product CLI's ordinary environment layers: the invoking directory's file outranks the Harness-home file, and both sit below the inherited environment. `loadLayeredEnv` snapshots each value's source, rejects [bootstrap-only file variables](../../../.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md#decision) case-insensitively, and materializes accepted values into `process.env` for Loader expressions and third-party libraries. Managed credentials live separately in [`.credentials.yaml`](../../credentials/credentials-local/README.md); a credential left in either `.env` remains a lower-priority fallback. - **`cordis.patch.yml`** (home level) and **`profiles//cordis.patch.yml`** — the user patch layers, applied after every bundle layer (per-profile first, then the home-level file, which therefore outranks it): 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 composed tree is a stderr warning. An empty or comments-only file throws (it parses to nothing, not to a list); disable the layer with `[]`. Long-lived surfaces keep `cordis.patch.yml` live through `watchUserPatches`; one-shot runs read only the startup value. The watcher targets the exact path even when the file or immediate parent does not exist, serializes bursts, and recomposes the user patches inside the caller's layer order (bundle layers below, overlay/flag patches above). A rejected read, parse, or Loader candidate leaves the last good tree running and the HMR service broadcasts `hmr/config-update-failed(filename, Error)` after logging it; observer failures are contained. Disposing the context closes the watcher and drains an active refresh. diff --git a/packages/ui/app-boot/README.zh.md b/packages/ui/app-boot/README.zh.md index 649a608026..a916236e30 100644 --- a/packages/ui/app-boot/README.zh.md +++ b/packages/ui/app-boot/README.zh.md @@ -37,7 +37,7 @@ profile 是位于 `$DSH_HOME/profiles/` 下的目录(Harness home 由 [` 用户级的机器本地偏好同样位于 Harness home 中: -- **`.env`**:产品 CLI 的普通环境层;调用目录的文件优先于 Harness home 的文件,两者都低于继承环境。`loadLayeredEnv` 记录每个值的来源,按不区分大小写的方式拒绝文件中的 bootstrap-only 进程、模块、运行时、Git 与网络变量,以及整个 `DSH_`/`XDG_`/`DYLD_`/`BASH_FUNC_` 命名空间,并把其余值物化进 `process.env`,供 Loader 表达式和第三方库使用。受管凭据另存于 [`.credentials.yaml`](../../credentials/credentials-local/README.md);留在任一 `.env` 中的凭据仍是低优先级后备值。 +- **`.env`**:产品 CLI 的普通环境层;调用目录的文件优先于 Harness home 的文件,两者都低于继承环境。`loadLayeredEnv` 记录每个值的来源,按不区分大小写的方式拒绝 [bootstrap-only 文件变量](../../../.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md#decision),并把其余值物化进 `process.env`,供 Loader 表达式和第三方库使用。受管凭据另存于 [`.credentials.yaml`](../../credentials/credentials-local/README.md);留在任一 `.env` 中的凭据仍是低优先级后备值。 - **`cordis.patch.yml`**(home 级)与 **`profiles//cordis.patch.yml`**:用户 patch 层,应用在所有组合包层之后(先应用逐 profile 的文件,再应用 home 级文件,因此后者优先级更高):按 id 定位的 patch 会替换对应条目的整个 `config`(未改字段也要重述),`insert` 会添加条目,`!!js` 表达式则在挂载时插值。如果 patch 指定的条目 id 不在组合后的树中,则输出一条 stderr 警告。空文件或仅含注释的文件会抛出异常(其解析结果为空,而不是列表);如需禁用该层,请使用 `[]`。 长期运行的 surface 会持续应用 `cordis.patch.yml` 的变更,具体由 `watchUserPatches` 负责;一次性运行只读取启动时的值。即使该文件或其直接父目录不存在,watcher 仍会监视确切路径;它会串行处理突发变更,并按调用方的层次顺序重新组合用户 patch(组合包层在下、overlay/标志 patch 在上)。读取失败、解析失败或 Loader 候选被拒时,最后一个可用树会继续运行;HMR 服务记录错误后广播 `hmr/config-update-failed(filename, Error)`,并隔离 observer 失败。上下文 dispose 时会关闭 watcher,并等待进行中的刷新结束。 diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index 34b09949c4..72c2e8137f 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -94,17 +94,12 @@ const BOOTSTRAP_NAMES = new Set([ 'PATH', 'HOME', 'USERPROFILE', 'SHELL', 'NODE_OPTIONS', 'NODE_PATH', 'NODE_EXTRA_CA_CERTS', 'LD_PRELOAD', 'LD_LIBRARY_PATH', 'LD_AUDIT', - // Interpreter start-up hooks: each of these makes a runtime execute a file - // of the setter's choosing on every invocation, before the program runs. - // `BASH_ENV` is the sharpest — the bash tool spawns `bash -c`, which sources - // it every time — but every runtime an agent shells out to has one. + // Interpreter startup hooks. 'BASH_ENV', 'ENV', 'SHELLOPTS', 'BASHOPTS', 'PERL5OPT', 'PERL5LIB', 'PYTHONSTARTUP', 'PYTHONPATH', 'RUBYOPT', 'RUBYLIB', 'JAVA_TOOL_OPTIONS', '_JAVA_OPTIONS', 'JDK_JAVA_OPTIONS', 'PYTHONHOME', - // Version-control hooks that run a command on the setter's behalf, and the - // config redirections that can define such a hook indirectly (a substituted - // git config file can set core.pager or a credential helper). + // Version-control command hooks and config redirects. 'GIT_SSH', 'GIT_SSH_COMMAND', 'GIT_EXTERNAL_DIFF', 'GIT_PAGER', 'GIT_EDITOR', 'GIT_ASKPASS', 'SSH_ASKPASS', 'GIT_CONFIG_GLOBAL', 'GIT_CONFIG_SYSTEM', 'GIT_CONFIG_COUNT', @@ -113,8 +108,6 @@ const BOOTSTRAP_NAMES = new Set([ 'SSL_CERT_FILE', 'SSL_CERT_DIR', 'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY', 'REQUESTS_CA_BUNDLE', 'CURL_CA_BUNDLE', - // Turns off TLS verification outright, which is the sharpest form of - // "how the network is trusted". 'NODE_TLS_REJECT_UNAUTHORIZED', ]) @@ -122,30 +115,8 @@ const BOOTSTRAP_NAMES = new Set([ const BOOTSTRAP_PREFIXES = ['DSH_', 'XDG_', 'DYLD_', 'BASH_FUNC_'] /** - * Whether a variable may come only from the inherited process environment. - * - * The invoking project is trusted to *configure* the agent's work — its - * endpoints, its ordinary variables, even a credential. It is not trusted to - * change the harness itself, and that is what a bootstrap variable does: it - * decides how a process launches (`PATH`, `NODE_OPTIONS`, `LD_PRELOAD`), what - * code a runtime executes before the program it was asked to run (`BASH_ENV` - * and its per-language siblings, the Git hook commands), where model-visible - * instructions load from (`DSH_*` covers the Harness home, the agents home, - * and the bundled skill root), or how the network is reached and trusted - * (proxy and CA variables). - * - * The distinction is that these take effect with no user action, before any - * turn, outside the permission policy and the sandbox — `DSH_PERMISSION_MODE` - * would switch off the approvals that make trusting a project meaningful at - * all, and `BASH_ENV` runs a file of the project's choosing on every single - * `bash -c` the tool issues. Trusting a project's code to run under the - * agent's policy is not the same as letting it rewrite that policy. - * - * They are therefore rejected at load rather than ranked below another layer: - * a user who wrote one into a file believes it applies, and silently ignoring - * it is its own failure. The whole `DSH_*` namespace is denied rather than an - * audited subset, because a switch added later must not become settable by - * being forgotten. + * Whether a variable may come only from the inherited process environment + * because it changes process, runtime, VCS, or network bootstrap. * @param name - the variable name. * @returns true when only the inherited environment may supply it. */ @@ -155,12 +126,8 @@ function isBootstrapOnly(name: string): boolean { } /** - * Parse one directory's `.env` without applying it, rejecting any bootstrap - * variable it declares. A discovered file must not decide how this process - * launches, where its code and model-visible instructions come from, or how it - * reaches the network, so a violation fails the launch BEFORE anything is - * materialized — reporting it afterwards would leave the process already - * running under the value it refused. + * Parse one directory's `.env` without applying it, rejecting bootstrap-only + * names before any value is materialized. * @param binName - the diagnostic prefix on the thrown error. * @param dir - the directory whose `.env` to read. * @param warn - sink for the one-line unreadable-file diagnostic. @@ -181,12 +148,7 @@ function readEnvLayer( // ENOENT (no .env) is fine — rely on the ambient environment. return undefined } - // `node:util`'s parseEnv is the same parser `--env-file` and - // `process.loadEnvFile` use. Checking with a second dialect (npm dotenv) - // would leave the rejection rule and the thing it guards on independently - // maintained parsers: a name Node accepts but the checker does not would - // reach `process.env` unchecked, and `BASH_ENV` there runs a file of the - // project's choosing on every `bash -c` the bash tool issues. + // Parse once so validation and materialization use exactly the same entries. const values = parseEnv(content) as Record for (const name of Object.keys(values)) { if (!isBootstrapOnly(name)) continue @@ -200,30 +162,10 @@ function readEnvLayer( } /** - * Load the dsh product CLI's user environment and return it as a snapshot that - * remembers which layer supplied each value: the invoking directory's `.env` - * over the Harness home's `.env`, both under the inherited process - * environment. - * - * Each layer is parsed once, checked, and only then applied — never replacing - * a name already set, which is what makes the layering `user < project < - * inherited`. The single parse is deliberate: the rejection rule and the - * values that reach `process.env` must come from the same parser, or a name - * one dialect accepts and the other misses would slip past the check. Values do reach - * `process.env`, because a user's own `--config` tree and third-party - * libraries read it; the returned snapshot is the authority for everything the - * harness itself resolves, since `process.env` alone cannot say whether a - * value came from the launching shell or from a file inside the workspace. - * - * The Harness home is resolved from the inherited environment *before* either - * file loads, so a project `.env` can never redirect which user document is - * read. Only the product CLI layers these files: an SDK or example bin loads - * its own directory through {@link loadEnv} and must not inherit a developer's - * `$DSH_HOME`. - * - * These are ordinary environment values with ordinary environment reach. A - * secret the Harness should own and isolate belongs in the credentials - * document, which is never materialized here. + * Load the product CLI's inherited > invoking-directory `.env` > Harness-home + * `.env` snapshot. The Harness home resolves before either file; both files + * are checked before either is applied, and accepted values are materialized + * without replacing inherited ones. The snapshot preserves source provenance. * @param binName - the diagnostic prefix on the diagnostics. * @param cwd - the invoking directory whose `.env` is the project layer. * @param warn - sink for the one-line misconfiguration diagnostics. @@ -239,12 +181,7 @@ export function loadLayeredEnv( // Parse both layers first: a rejection must not leave one file applied. const project = readEnvLayer(binName, cwd, warn) const user = home === resolve(cwd) ? undefined : readEnvLayer(binName, home, warn) - // Assign the entries this function already parsed and checked, rather than - // re-reading each file through `process.loadEnvFile`. One parse means the - // snapshot, the rejection rule, and `process.env` can never disagree about - // what a file contains. Skipping names already set reproduces the - // never-replace behavior that makes the layering `user < project < - // inherited`. + // Apply the checked values without replacing a higher-ranked name. for (const layer of [project, user]) { if (layer === undefined) continue for (const [name, value] of Object.entries(layer.values)) { diff --git a/packages/ui/app-boot/tests/app-boot.spec.ts b/packages/ui/app-boot/tests/app-boot.spec.ts index 447b2f5949..baeb98fe77 100644 --- a/packages/ui/app-boot/tests/app-boot.spec.ts +++ b/packages/ui/app-boot/tests/app-boot.spec.ts @@ -114,8 +114,6 @@ describe('loadLayeredEnv', () => { const warn = vi.fn() try { loadLayeredEnv(NAME, project, warn) - // Both files load; the project layer wins the name they share, and the - // inherited environment wins over both. expect(process.env[NAMES[0]]).toBe('project') expect(process.env[NAMES[1]]).toBe('user-only') expect(process.env[NAMES[2]]).toBe('project-only') @@ -142,8 +140,6 @@ describe('loadLayeredEnv', () => { vi.stubEnv('DSH_HOME', home) try { expect(() => loadLayeredEnv(NAME, project, vi.fn())).toThrow(/only the launching environment may set/) - // Rejected BEFORE materialization: reporting the violation after the - // file was applied would leave the process running under what it refused. expect(process.env[NAMES[1]]).toBeUndefined() } finally { clear() @@ -162,7 +158,6 @@ describe('loadLayeredEnv', () => { const snapshot = loadLayeredEnv(NAME, project, vi.fn()) expect(snapshot.get(NAMES[1])).toEqual({ value: 'u', source: 'user-env', path: join(home, '.env') }) expect(snapshot.get(NAMES[2])).toEqual({ value: 'p', source: 'project-env', path: join(project, '.env') }) - // getFrom is a refusal, not a demotion: an omitted layer is invisible. expect(snapshot.getFrom(NAMES[2], ['process', 'user-env'])).toBeUndefined() } finally { clear() @@ -190,9 +185,7 @@ describe('loadLayeredEnv', () => { it('warns and continues when a layer exists but cannot be read', () => { const home = tmp() const project = tmp() - // A directory named `.env` is present-but-unreadable (EISDIR): unlike an - // absent file, it is a real misconfiguration, so it is reported rather - // than passed over in silence — and the other layers still load. + // A directory named `.env` is a present-but-unreadable layer. mkdirSync(join(home, '.env')) writeFileSync(join(project, '.env'), `${NAMES[2]}=project-only\n`) clear() @@ -238,8 +231,6 @@ describe('loadLayeredEnv', () => { vi.stubEnv('DSH_HOME', home) const warn = vi.fn() try { - // No user `.env` exists, which is ordinary rather than a fault: the - // layer is simply absent, and nothing is reported. const snapshot = loadLayeredEnv(NAME, project, warn) expect(warn).not.toHaveBeenCalled() expect(snapshot.get(NAMES[2])).toEqual({ value: 'project-only', source: 'project-env', path: join(project, '.env') }) @@ -270,9 +261,6 @@ describe('loadLayeredEnv', () => { clear() vi.stubEnv('DSH_HOME', both) try { - // One file cannot be two layers. It is the project layer, because that - // is the more trusted of the two — reading it twice would otherwise - // put the same path at two different ranks. const snapshot = loadLayeredEnv(NAME, both, vi.fn()) expect(snapshot.get(NAMES[2])).toEqual({ value: 'one-file', source: 'project-env', path: join(both, '.env') }) } finally { diff --git a/packages/util/environment/src/index.ts b/packages/util/environment/src/index.ts index 939ddba633..741e5752cd 100644 --- a/packages/util/environment/src/index.ts +++ b/packages/util/environment/src/index.ts @@ -1,15 +1,8 @@ /** - * The launch-time environment as one immutable snapshot that remembers which - * layer supplied each value. The harness resolves user-facing values against - * this rather than against `process.env`, because the layers differ in how - * much they are trusted: an inherited variable is this run's explicit intent, - * a file discovered under the invoking directory is whatever the project - * happens to contain, and a consumer that cannot tell them apart cannot make - * that distinction. - * - * Values still reach `process.env` as well — a user's own `--config` tree and - * third-party libraries read it — but that flattened view is not the - * authority for anything the harness itself resolves. + * Immutable launch-time environment snapshot with per-value source + * provenance. Harness consumers resolve through it instead of a flattened + * `process.env`; launchers may still materialize accepted values for config + * expressions and third-party libraries. * @module @deepseek-ai/dsh-environment */ @@ -49,10 +42,8 @@ export interface EnvironmentSnapshot { */ get(name: string): EnvironmentEntry | undefined /** - * Resolve one name across only the layers the caller trusts for this - * decision. Omitting a layer is a refusal, not a demotion: a routing field - * that must never come from a project directory omits `project-env` so no - * ordering change can let it back in. + * Resolve one name only from `sources`, retaining canonical trust order; + * omitted layers are unreachable. * @param name - the variable name. * @param sources - the layers allowed in the canonical trust order. * @returns the first matching entry, or `undefined`. @@ -85,13 +76,8 @@ export interface EnvironmentLayerInput { * @returns the immutable snapshot. */ export function createEnvironmentSnapshot(layers: readonly EnvironmentLayerInput[]): EnvironmentSnapshot { - // Copied per layer so a later mutation of `process.env` — or of a caller's - // own object — cannot change what this snapshot reports. Windows environment - // names are case-insensitive, so lookups there fold case: otherwise a shell - // that set `deepseek_api_key` would be invisible to a consumer asking for - // `DEEPSEEK_API_KEY`, and a lower-ranked layer spelling it in caps would win - // a decision the launch had already made. POSIX names are case-sensitive and - // must stay exact. + // Copy every layer so later mutations cannot change the snapshot. Fold names + // on Windows so case variants cannot split precedence; POSIX remains exact. const bySource = new Map }>() for (const layer of layers) { bySource.set(layer.source, { @@ -120,15 +106,8 @@ export function createEnvironmentSnapshot(layers: readonly EnvironmentLayerInput export const DSH_ENVIRONMENT_KEY = 'launcherEnvironment' /** - * The snapshot to resolve against, whatever booted this tree: the launcher's - * when the product CLI provided one, otherwise the inherited environment - * alone. - * - * The fallback does not weaken the layer rules — it applies the same rules to - * a host that has exactly one layer. An SDK embedder or a bare `cordis.yml` - * never discovered a project or user file, so everything it has really is the - * environment it was launched with, and `getFrom(..., ['process'])` is exactly - * right for it. + * Return the launcher's snapshot, or the inherited environment as the sole + * layer when the host provided none. * @param ctx - the consuming plugin's context. * @returns the snapshot to resolve user-facing values against. */ diff --git a/scripts/verify-config-source-ownership.ts b/scripts/verify-config-source-ownership.ts index e027fcba61..b0f4b89cdf 100644 --- a/scripts/verify-config-source-ownership.ts +++ b/scripts/verify-config-source-ownership.ts @@ -1,8 +1,6 @@ /** - * Gate: shipped Cordis configuration does not use the ordinary inline form - * for a credential or endpoint from the environment. This narrow source-shape - * lint prevents checked-in composition from bypassing the credential seam and - * endpoint ladder; adapters remain responsible for actual value resolution. + * Gate for forbidden credential or endpoint environment inlines in shipped + * Cordis configuration. * @module scripts/verify-config-source-ownership */ @@ -21,13 +19,7 @@ const SHIPPED_CONFIG_GLOBS = [ 'python/*/src/**/cordis.yml', ] -/** - * Config keys that must never be inlined from the environment. Line-anchored - * on purpose: this is a tripwire for the shape people actually write, not a - * YAML analysis. A folded scalar or a block-literal spelling would slip past - * it, which is acceptable because the rule it guards is also stated in the - * owning Agent Note and enforced by the adapters' own resolution. - */ +/** Ordinary single-line forms this narrow source-shape check rejects; not full YAML analysis. */ const INLINE_DENY = /^\s*(apiKey|baseURL|apiKeyEnv|authToken|headers)\s*:\s*!!js\b/ /** Return every forbidden inline environment form in shipped configuration. */ From 510976351cb28d6ce5307a87db5c80e72de95c95 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 00:34:42 +0800 Subject: [PATCH 26/27] test(telemetry): restore expression-tag coverage --- packages/sdk/telemetry/tests/consent-resolver.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/sdk/telemetry/tests/consent-resolver.spec.ts b/packages/sdk/telemetry/tests/consent-resolver.spec.ts index 05442bcc0f..b9c9300e78 100644 --- a/packages/sdk/telemetry/tests/consent-resolver.spec.ts +++ b/packages/sdk/telemetry/tests/consent-resolver.spec.ts @@ -78,6 +78,7 @@ describe('ConsentResolver cordis.yml state', () => { ' name: \'@deepseek-ai/dsh-llm-deepseek\'', ' config:', ' apiKeyEnv: DEEPSEEK_API_KEY', + ' model: !!js process.env.DEEPSEEK_MODEL', '', ].join('\n') expect(await resolver.resolve(await projectDir(yml))) From e46b082fee3aa811699ae8d623f32044b3ee029f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 01:32:27 +0800 Subject: [PATCH 27/27] test(snapshot): derive compaction replay from logs --- docs/config-catalog.md | 2 +- docs/module-graph.md | 9 +- .../compaction.cordis.snapshot.yml | 25 ++++++ .../headless-agent/tests/compaction.e2e.ts | 4 +- .../headless-agent/tests/headless.snapshot.ts | 73 ++++++++++++++++ .../snapshots/compaction-recovery/input.json | 8 ++ .../compaction-recovery/session.jsonl | 32 +++++++ .../stream-json.expected.jsonl | 32 +++++++ packages/support/llm-replay/README.i18n.yaml | 4 +- packages/support/llm-replay/README.md | 10 ++- packages/support/llm-replay/README.zh.md | 10 ++- packages/support/llm-replay/package.json | 2 + packages/support/llm-replay/src/index.ts | 39 +++++++-- .../llm-replay/tests/llm-replay.spec.ts | 87 +++++++++++++++++++ packages/support/llm-replay/tsconfig.json | 3 + pnpm-lock.yaml | 3 + 16 files changed, 317 insertions(+), 26 deletions(-) create mode 100644 examples/headless-agent/compaction.cordis.snapshot.yml create mode 100644 examples/headless-agent/tests/snapshots/compaction-recovery/input.json create mode 100644 examples/headless-agent/tests/snapshots/compaction-recovery/session.jsonl create mode 100644 examples/headless-agent/tests/snapshots/compaction-recovery/stream-json.expected.jsonl diff --git a/docs/config-catalog.md b/docs/config-catalog.md index feb8aa9d86..f4302984a8 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -857,7 +857,7 @@ export interface ReplayModelConfig { Depends on: [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) -Source: [`packages/support/llm-replay/src/index.ts:710`](../packages/support/llm-replay/src/index.ts) +Source: [`packages/support/llm-replay/src/index.ts:731`](../packages/support/llm-replay/src/index.ts) ## `@deepseek-ai/dsh-llm-retry` diff --git a/docs/module-graph.md b/docs/module-graph.md index 14a6b71dc1..d963273363 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -422,9 +422,6 @@ flowchart TD pkg_session_persistence --> pkg_brand pkg_session_persistence --> pkg_invariants pkg_session_persistence --> pkg_session - pkg_llm_replay --> pkg_invariants - pkg_llm_replay --> pkg_llm - pkg_llm_replay --> pkg_session pkg_app_boot --> pkg_environment pkg_app_boot --> pkg_invariants pkg_app_boot --> pkg_paths @@ -500,6 +497,10 @@ flowchart TD pkg_session_title --> pkg_llm pkg_session_title --> pkg_session pkg_session_title --> pkg_session_projection + pkg_llm_replay --> pkg_compact + pkg_llm_replay --> pkg_invariants + pkg_llm_replay --> pkg_llm + pkg_llm_replay --> pkg_session pkg_commands --> pkg_agent pkg_commands --> pkg_brand pkg_commands --> pkg_invariants @@ -1219,7 +1220,6 @@ flowchart TD | [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`web`](../packages/web/web) | | [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`session-persistence`](../packages/session-persistence/session-persistence) | `session-persistence` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | -| [`llm-replay`](../packages/support/llm-replay) | `support` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`app-boot`](../packages/ui/app-boot) | `ui` | [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`system-prompt`](../packages/core/system-prompt) | | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | @@ -1239,6 +1239,7 @@ flowchart TD | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl) | `session-persistence` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | | [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | `session-persistence` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | | [`session-title`](../packages/session-title/session-title) | `session-title` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection) | +| [`llm-replay`](../packages/support/llm-replay) | `support` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`commands`](../packages/ui/commands) | `ui` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session) | | [`user-approval`](../packages/ui/user-approval) | `ui` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | diff --git a/examples/headless-agent/compaction.cordis.snapshot.yml b/examples/headless-agent/compaction.cordis.snapshot.yml new file mode 100644 index 0000000000..42fc5306ac --- /dev/null +++ b/examples/headless-agent/compaction.cordis.snapshot.yml @@ -0,0 +1,25 @@ +# Keyless context-overflow composition for the assembled compaction snapshot. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - id: compact-basic + name: '@deepseek-ai/dsh-compact-basic' + config: + thresholdRatio: 0.99 + retainTokens: 20 + maxTokens: 32 + compactionRetries: 1 + - insert: + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek-official + models: + - id: deepseek-v4-flash + contextWindow: 128000 diff --git a/examples/headless-agent/tests/compaction.e2e.ts b/examples/headless-agent/tests/compaction.e2e.ts index 07f239a73e..6fe6f4055b 100644 --- a/examples/headless-agent/tests/compaction.e2e.ts +++ b/examples/headless-agent/tests/compaction.e2e.ts @@ -11,8 +11,8 @@ import { SessionId } from '@deepseek-ai/dsh-session' * Key-gated smoke for mid-session compaction. It verifies the compact event * pair, replacement of older surface nodes, and a final answer after compaction. */ -// FIXME(compaction-snapshot): this is the only full compaction coverage because -// replay cannot serve the summarizer's unlogged model call. +// The keyless headless snapshot pins deterministic overflow recovery; this test +// remains the independent live-provider smoke for organic pressure and summary quality. let workdir: string | undefined let ctx: Context | undefined diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts index 121bbf75ed..f9cb46111a 100644 --- a/examples/headless-agent/tests/headless.snapshot.ts +++ b/examples/headless-agent/tests/headless.snapshot.ts @@ -29,6 +29,10 @@ const goalScenarioDir = join(snapshotsDir, 'goal-tools') const goalConfigPath = fileURLToPath(new URL('../goal.cordis.snapshot.yml', import.meta.url)) const retryScenarioDir = join(snapshotsDir, 'provider-retry') const retryConfigPath = fileURLToPath(new URL('../retry.cordis.snapshot.yml', import.meta.url)) +const compactionScenarioDir = join(snapshotsDir, 'compaction-recovery') +const compactionSessionFixture = join(compactionScenarioDir, 'session.jsonl') +const compactionStreamExpected = join(compactionScenarioDir, 'stream-json.expected.jsonl') +const compactionConfigPath = fileURLToPath(new URL('../compaction.cordis.snapshot.yml', import.meta.url)) const credentialsScenarioDir = join(snapshotsDir, 'missing-credential') const credentialsConfigPath = fileURLToPath(new URL('../credentials.cordis.snapshot.yml', import.meta.url)) // Same keyless composition as the missing-credential scenario: the endpoint is @@ -227,6 +231,75 @@ describe('headless stream-json snapshots', () => { expect(normalized).toBe(await readFile(streamExpected, 'utf8')) }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('recovers from context overflow through an assembled compaction', async () => { + const prompt = await scenarioPrompt(compactionScenarioDir, 'compaction-recovery') + let expectedSession = await readFile(compactionSessionFixture, 'utf8') + let runCwd = '' + const result = await runLoaderSmoke({ + label: 'compaction recovery headless stream-json snapshot', + tempDirPrefix: 'headless-snapshot-compaction-recovery-', + binScript, + configPath: compactionConfigPath, + binArgs: ['--config', compactionConfigPath, '--output-format', 'stream-json', prompt], + tsconfigPath, + env: { + DSH_SNAPSHOT: 'replay', + DSH_SNAPSHOT_FILE: compactionSessionFixture, + NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '), + }, + prepare: (cwd) => { runCwd = cwd }, + inspect: async (cwd) => { + const logs = await persistedLogs(cwd) + expect(logs).toHaveLength(1) + const actual = logs[0] + if (actual === undefined) throw new Error('compaction snapshot did not persist its session') + const records = parseJsonl(actual.content) + const types = records.map(record => record.type) + expect(types.filter(type => type === 'compact/start')).toHaveLength(1) + expect(types.filter(type => type === 'compact/summary')).toHaveLength(1) + expect(types.filter(type => type === 'compact/end')).toHaveLength(1) + const start = types.indexOf('compact/start') + const summary = types.indexOf('compact/summary') + const replacement = records.findIndex((record) => { + if (record.type !== 'user/message') return false + const surfaceOp = record.surfaceOp as JsonObject | undefined + return surfaceOp?.op === 'replace' + }) + const end = types.indexOf('compact/end') + expect(start).toBeLessThan(summary) + expect(summary).toBeLessThan(replacement) + expect(replacement).toBeLessThan(end) + const summaryRecord = records[summary] + const summaryData = summaryRecord?.data as JsonObject | undefined + expect(summaryData?.shadowedSeqs).toEqual(expect.arrayContaining([expect.any(Number)])) + const final = [...records].reverse().find(record => record.type === 'assistant/message') + expect(JSON.stringify(final)).toContain('COMPACTION RECOVERED') + + const actualContext = contextFromLogs([actual.content]) + if (refreshing) { + const harvested: HarvestedLog = { + id: String(actual.header.id), + createdAt: Number(actual.header.createdAt), + content: actual.content, + } + const replacements = refreshFixtureReplacements([harvested], [expectedSession]) + expectedSession = tokenizeSessionFixtureCwd( + stabilizeRefreshLog(actual.content, expectedSession, replacements, actualContext), + ) + await writeFile(compactionSessionFixture, expectedSession) + } + const expectedContext = contextFromLogs([expectedSession]) + expect(scrubRequestHeaders(normalizeSessionLog(actual.content, actualContext))) + .toBe(scrubRequestHeaders(normalizeSessionLog(expectedSession, expectedContext))) + }, + }) + + expect(result.stderr).toBe('') + const normalized = normalizeHeadlessStream(result.stdout, runCwd) + if (refreshing) await writeFile(compactionStreamExpected, normalized) + expect(normalized).toBe(await readFile(compactionStreamExpected, 'utf8')) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('logs actionable missing-credential guidance through the one-shot app', async () => { const streamExpected = join(credentialsScenarioDir, 'stream-json.expected.jsonl') let runCwd = '' diff --git a/examples/headless-agent/tests/snapshots/compaction-recovery/input.json b/examples/headless-agent/tests/snapshots/compaction-recovery/input.json new file mode 100644 index 0000000000..3ccad96b83 --- /dev/null +++ b/examples/headless-agent/tests/snapshots/compaction-recovery/input.json @@ -0,0 +1,8 @@ +{ + "steps": [ + { + "op": "prompt", + "text": "Establish a durable compaction premise before continuing. Record every part of this historical evidence: the snapshot uses keyless replay; persistence uses JSONL; the assembled headless application loads its real Cordis composition; model-visible inputs remain logged; tool calls and results remain paired and ordered; context overflow retains the original failure while recovery is attempted; compaction opens with compact/start and closes with compact/end; a successful auxiliary summary records compact/summary provenance; the replacement surface shadows only an older balanced range; the checkpoint remains smaller than the history it replaces; the newest tool result remains verbatim; the retried request sees that checkpoint; the final response proves the same turn continued; deterministic snapshot evidence stays separate from the live-provider smoke; and no external API key is needed. Emit one alpha marker through bash, then finish the task after any required recovery with the exact words COMPACTION RECOVERED." + } + ] +} diff --git a/examples/headless-agent/tests/snapshots/compaction-recovery/session.jsonl b/examples/headless-agent/tests/snapshots/compaction-recovery/session.jsonl new file mode 100644 index 0000000000..855e66ac14 --- /dev/null +++ b/examples/headless-agent/tests/snapshots/compaction-recovery/session.jsonl @@ -0,0 +1,32 @@ +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"agent/inbox/spliced","seq":0,"time":1786123401613,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Establish a durable compaction premise before continuing. Record every part of this historical evidence: the snapshot uses keyless replay; persistence uses JSONL; the assembled headless application loads its real Cordis composition; model-visible inputs remain logged; tool calls and results remain paired and ordered; context overflow retains the original failure while recovery is attempted; compaction opens with compact/start and closes with compact/end; a successful auxiliary summary records compact/summary provenance; the replacement surface shadows only an older balanced range; the checkpoint remains smaller than the history it replaces; the newest tool result remains verbatim; the retried request sees that checkpoint; the final response proves the same turn continued; deterministic snapshot evidence stays separate from the live-provider smoke; and no external API key is needed. Emit one alpha marker through bash, then finish the task after any required recovery with the exact words COMPACTION RECOVERED."}],"source":{"kind":"user"},"role":"user","id":"6335ca4a-a577-47dd-8219-aa81f39cdbc0"}]}} +{"type":"turn/start","seq":1,"time":1786123401614,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1786123401614,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1786123401667,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1786123401667,"data":{"content":[{"type":"text","text":"Establish a durable compaction premise before continuing. Record every part of this historical evidence: the snapshot uses keyless replay; persistence uses JSONL; the assembled headless application loads its real Cordis composition; model-visible inputs remain logged; tool calls and results remain paired and ordered; context overflow retains the original failure while recovery is attempted; compaction opens with compact/start and closes with compact/end; a successful auxiliary summary records compact/summary provenance; the replacement surface shadows only an older balanced range; the checkpoint remains smaller than the history it replaces; the newest tool result remains verbatim; the retried request sees that checkpoint; the final response proves the same turn continued; deterministic snapshot evidence stays separate from the live-provider smoke; and no external API key is needed. Emit one alpha marker through bash, then finish the task after any required recovery with the exact words COMPACTION RECOVERED."}],"source":{"kind":"user"},"role":"user","id":"6335ca4a-a577-47dd-8219-aa81f39cdbc0"},"surfaceOp":"append"} +{"type":"session/title","seq":5,"time":1786123401667,"data":{"title":"Establish a durable compaction premise","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":6,"time":1786123401668,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/context","seq":7,"time":1786123401669,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":128000}} +{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_compaction_marker","name":"bash","argumentsDelta":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}}} +{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_compaction_marker","name":"bash","arguments":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}}}} +{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":24,"outputTokens":6}}}} +{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":13,"time":1786123401680,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_compaction_marker","name":"bash","arguments":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"e54f73a8-572a-40ee-b908-8a8a27b83bf8"},"usage":{"inputTokens":24,"outputTokens":6}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} +{"type":"tool/call","seq":14,"time":1786123401680,"data":{"turn":1,"step":1,"callId":"call_compaction_marker","name":"bash","arguments":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}} +{"type":"tool/result","seq":15,"time":1786123401700,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_compaction_marker"},"content":[{"type":"tool-result","toolCallId":"call_compaction_marker","content":[{"type":"text","text":"alpha\n"}],"isError":false}],"role":"user","id":"b4a6504e-f39d-40b0-b51a-b11fbd60b135"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"step/end","seq":16,"time":1786123401700,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":17,"time":1786123401710,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":18,"time":1786123401715,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"snapshot request exceeded the model context window","code":"CONTEXT_WINDOW_EXCEEDED"}}}}} +{"type":"compact/start","seq":19,"time":1786123401715,"data":{"turn":1}} +{"type":"compact/summary","seq":20,"time":1786123401725,"data":{"summary":[{"type":"text","text":"The request established a durable compaction premise."}],"rawOutput":[{"type":"text","text":"The request established a durable compaction premise."}],"shadowedRange":{"start":4,"end":4},"shadowedSeqs":[4],"shadowedTokenCount":264,"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":32,"usage":{"inputTokens":20,"outputTokens":4}}} +{"type":"user/message","seq":21,"time":1786123401725,"data":{"content":[{"type":"text","text":"This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.\n\n"},{"type":"text","text":"The request established a durable compaction premise."},{"type":"text","text":""}],"source":{"kind":"plugin","plugin":"compact"},"role":"user","id":"6d2afb13-a37b-48d6-9ea5-fc8734127377"},"sourceEventSeqs":[19,20,4],"surfaceOp":{"op":"replace","start":4,"end":4}} +{"type":"compact/end","seq":22,"time":1786123401725,"data":{"turn":1}} +{"type":"assistant/chunk","seq":23,"time":1786123401730,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":24,"time":1786123401730,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"COMPACTION RECOVERED"}}} +{"type":"assistant/chunk","seq":25,"time":1786123401730,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"COMPACTION RECOVERED"}}}} +{"type":"assistant/chunk","seq":26,"time":1786123401730,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":20,"outputTokens":4}}}} +{"type":"assistant/chunk","seq":27,"time":1786123401730,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":28,"time":1786123401730,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"COMPACTION RECOVERED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"346742d0-50e3-4594-b53c-f26c7da82c56"},"usage":{"inputTokens":20,"outputTokens":4}},"sourceEventSeqs":[23,24,25,26,27],"surfaceOp":"append"} +{"type":"step/end","seq":29,"time":1786123401730,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":30,"time":1786123401730,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/snapshots/compaction-recovery/stream-json.expected.jsonl b/examples/headless-agent/tests/snapshots/compaction-recovery/stream-json.expected.jsonl new file mode 100644 index 0000000000..4d798adc37 --- /dev/null +++ b/examples/headless-agent/tests/snapshots/compaction-recovery/stream-json.expected.jsonl @@ -0,0 +1,32 @@ +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":0,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Establish a durable compaction premise before continuing. Record every part of this historical evidence: the snapshot uses keyless replay; persistence uses JSONL; the assembled headless application loads its real Cordis composition; model-visible inputs remain logged; tool calls and results remain paired and ordered; context overflow retains the original failure while recovery is attempted; compaction opens with compact/start and closes with compact/end; a successful auxiliary summary records compact/summary provenance; the replacement surface shadows only an older balanced range; the checkpoint remains smaller than the history it replaces; the newest tool result remains verbatim; the retried request sees that checkpoint; the final response proves the same turn continued; deterministic snapshot evidence stays separate from the live-provider smoke; and no external API key is needed. Emit one alpha marker through bash, then finish the task after any required recovery with the exact words COMPACTION RECOVERED."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":1,"time":0,"data":{"turn":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":2,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":4,"time":0,"data":{"content":[{"type":"text","text":"Establish a durable compaction premise before continuing. Record every part of this historical evidence: the snapshot uses keyless replay; persistence uses JSONL; the assembled headless application loads its real Cordis composition; model-visible inputs remain logged; tool calls and results remain paired and ordered; context overflow retains the original failure while recovery is attempted; compaction opens with compact/start and closes with compact/end; a successful auxiliary summary records compact/summary provenance; the replacement surface shadows only an older balanced range; the checkpoint remains smaller than the history it replaces; the newest tool result remains verbatim; the retried request sees that checkpoint; the final response proves the same turn continued; deterministic snapshot evidence stays separate from the live-provider smoke; and no external API key is needed. Emit one alpha marker through bash, then finish the task after any required recovery with the exact words COMPACTION RECOVERED."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":5,"time":0,"data":{"title":"Establish a durable compaction premise","messageSeqs":[4],"source":{"kind":"fallback"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":6,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":7,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":128000}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_compaction_marker","name":"bash","argumentsDelta":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_compaction_marker","name":"bash","arguments":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":24,"outputTokens":6}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":13,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_compaction_marker","name":"bash","arguments":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":24,"outputTokens":6}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":14,"time":0,"data":{"turn":1,"step":1,"callId":"call_compaction_marker","name":"bash","arguments":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":15,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_compaction_marker"},"content":[{"type":"tool-result","toolCallId":"call_compaction_marker","content":[{"type":"text","text":"alpha\n"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":16,"time":0,"data":{"turn":1,"step":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":17,"time":0,"data":{"turn":1,"step":2}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"snapshot request exceeded the model context window","code":"CONTEXT_WINDOW_EXCEEDED"}}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"compact/start","seq":19,"time":0,"data":{"turn":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"compact/summary","seq":20,"time":0,"data":{"summary":[{"type":"text","text":"The request established a durable compaction premise."}],"rawOutput":[{"type":"text","text":"The request established a durable compaction premise."}],"shadowedRange":{"start":4,"end":4},"shadowedSeqs":[4],"shadowedTokenCount":264,"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":32,"usage":{"inputTokens":20,"outputTokens":4}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":21,"time":0,"data":{"content":[{"type":"text","text":"This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.\n\n"},{"type":"text","text":"The request established a durable compaction premise."},{"type":"text","text":""}],"source":{"kind":"plugin","plugin":"compact"},"role":"user","id":"{{sessionId}}"},"sourceEventSeqs":[19,20,4],"surfaceOp":{"op":"replace","start":4,"end":4}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"compact/end","seq":22,"time":0,"data":{"turn":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":23,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":24,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"COMPACTION RECOVERED"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"COMPACTION RECOVERED"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":20,"outputTokens":4}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":28,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"COMPACTION RECOVERED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":20,"outputTokens":4}},"sourceEventSeqs":[23,24,25,26,27],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":29,"time":0,"data":{"turn":1,"step":2}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":30,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}} +{"type":"result","sessionId":"{{sessionId}}","output":"COMPACTION RECOVERED","usage":{"inputTokens":44,"outputTokens":10}} diff --git a/packages/support/llm-replay/README.i18n.yaml b/packages/support/llm-replay/README.i18n.yaml index a4729b2e69..3f3e349a84 100644 --- a/packages/support/llm-replay/README.i18n.yaml +++ b/packages/support/llm-replay/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/support/llm-replay/README.md -README.md: ee062d0c2804905f33f1ff476d12bb6dd57666e5 -README.zh.md: ab3420d9500a6ca77f04a2ad96095f8883aeb874 +README.md: 46d391970f320708914d11f0868cbbc5361ae196 +README.zh.md: a67b078a1396968dc3ddecb0e616a832c4faaf3a diff --git a/packages/support/llm-replay/README.md b/packages/support/llm-replay/README.md index ee062d0c28..46d391970f 100644 --- a/packages/support/llm-replay/README.md +++ b/packages/support/llm-replay/README.md @@ -8,7 +8,9 @@ Its consumers are the ACP and headless `stream-json` snapshot suites plus the We ## How the fixture works -The fixture IS the persisted session log (`/session.jsonl`). Its `assistant/chunk` events carry every `StreamChunk`, so grouping them by `(turn, step)` reconstructs each `stream()` call's chunk sequence (one model call per loop step). Recording is therefore "run the real agent once and harvest the `.jsonl`", done by the snapshot harness — this plugin does not record. A fixture may carry its `request/header` content tokenized to `{{system}}`/`{{tools}}` (the harness pins that content in one scenario and scrubs the rest); replay is indifferent — derivation reads only `assistant/chunk` events and the line-0 session header. +The fixture IS the persisted session log (`/session.jsonl`). Its `assistant/chunk` events carry every `StreamChunk`, so grouping them by `(turn, step)` reconstructs each agent-loop `stream()` call's chunk sequence. A successful compaction summarizer is logged differently: when `compact/summary` carries its complete `rawOutput`, replay reconstructs a canonical successful stream at that event's position using one `block-start`/`block-end` pair per block, the recorded usage when present, and a terminal `stop`. Exact provider delta partitioning is not part of the durable compaction result. A summary without `rawOutput` does not imply an LLM call because template and remote summarizers may produce it without the local adapter. + +Recording is therefore "run the real agent once and harvest the `.jsonl`", done by the snapshot harness — this plugin does not record. A fixture may carry its `request/header` content tokenized to `{{system}}`/`{{tools}}` (the harness pins that content in one scenario and scrubs the rest); replay is indifferent — derivation reads only `assistant/chunk` and `compact/summary` events plus the line-0 session header. Two failure modes are not reconstructable from `assistant/chunk` alone — a pure throw before any chunk (e.g. an HTTP 401, where the log holds only a `turn/end {error}` and no chunks) and a cancel/hang (timing, not chunk content). A scenario that needs those supplies an optional sidecar (`/replay.override.json`) that either replaces the derived script (a bare `ReplayEntry[]`) or augments it (`{ patches: [{ at, entry }] }`: keep every JSONL-derived call and swap the named 0-based call indexes; `at` equal to the derived length appends the retry attempt after an injected transient throw). Patch indexes must be unique. The override document, each patch and entry, and every chunk discriminant are validated when the file loads. A `hang` entry may name `readyFile`; replay writes that empty marker after its prefix chunks reach the loop and before it waits for cancellation, so an external driver can cancel deterministically without observing a presentation update. @@ -57,7 +59,7 @@ Replay keys every call by its calling session id (`GenerateOptions.sessionId`, s - `installLlmReplay(ctx, config)` — install the configured replay adapter or catch-all `llm/stream` listener; returns a `ReplayHandle` (`dispose()` for HMR safety plus `assertConsumed()`, the teardown check that every recorded script bound to a live session and every bound cursor drained — turning a scenario that silently drove fewer model calls than recorded into a crisp diagnostic). Use this in tests to drive replay without the Loader or env vars. - `loadSessionScripts(config)` — resolve the ordered `SessionScript[]` (primary + children) for a scenario, ready to bind to live sessions in first-call order. - `loadReplayScript(config)` — resolve the `ReplayEntry[]` for the primary session only (validated sidecar replacement/patches if present, else derived from the JSONL; fail-loud if the fixture is missing). -- `deriveReplayScript(events)` / `parseSessionLog(text)` / `parseSessionHeader(text)` / `resolveScriptedEntry(entry, messages)` — the pure helpers that turn a recorded session log into a script, read its header `id`/`createdAt`, and resolve `{{fromRequest:...}}` placeholders against one live request. A derived group must end in a `finish` chunk; a group without one is the fingerprint of a thrown `stream()` and must instead be expressed via an override sidecar. +- `deriveReplayScript(events)` / `parseSessionLog(text)` / `parseSessionHeader(text)` / `resolveScriptedEntry(entry, messages)` — the pure helpers that turn ordinary loop chunks and complete compaction outputs in a recorded session log into a script, read its header `id`/`createdAt`, and resolve `{{fromRequest:...}}` placeholders against one live request. A derived assistant group must end in a `finish` chunk; a group without one is the fingerprint of a thrown `stream()` and must instead be expressed via an override sidecar. - Types `ReplayEntry` / `ReplayOverrideDoc` / `ReplayOverridePatch` / `SessionScript` / `ReplayConfig` / `ReplayProviderConfig` / `ReplayModelConfig` / `ReplayHandle` / `Config`. ## Plugin export shape @@ -74,5 +76,5 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **First-call-order script binding assumes sequential delegation** — a cut that runs sibling subagents concurrently (or a compaction summarize call landing mid-run) would bind live sessions to recorded scripts non-deterministically; a stronger keying is deferred until such a scenario exists (`XXX(concurrent-subagents)`). -- **Only chunk-producing calls are derivable** — a pure pre-chunk throw or a cancel/hang scenario needs the `replay.override.json` sidecar. Replacement and patch forms affect only the primary session; child scripts still derive from their logs. +- **First-call-order script binding assumes sequential delegation** — a cut that runs sibling subagents concurrently would bind live sessions to recorded scripts non-deterministically; a stronger keying is deferred until such a scenario exists (`XXX(concurrent-subagents)`). +- **Only ordinary loop chunks and completed compaction outputs are derivable** — a pure pre-chunk throw or a cancel/hang scenario needs the `replay.override.json` sidecar. Replacement and patch forms affect only the primary session; child scripts still derive from their logs. diff --git a/packages/support/llm-replay/README.zh.md b/packages/support/llm-replay/README.zh.md index ab3420d950..a67b078a13 100644 --- a/packages/support/llm-replay/README.zh.md +++ b/packages/support/llm-replay/README.zh.md @@ -8,7 +8,9 @@ ## fixture 的工作方式 -fixture 就是持久化的会话日志(`/session.jsonl`)。其 `assistant/chunk` 事件包含每个 `StreamChunk`,因此按 `(turn, step)` 分组即可重建每次 `stream()` 调用的分片序列(每个循环步骤调用一次模型)。因此,录制就是「运行一次真实 agent 并收集 `.jsonl`」,由快照 harness 完成;该插件本身不录制。fixture 的 `request/header` 内容可能被标记化为 `{{system}}`/`{{tools}}`(harness 会在一个场景中固定该内容,并清除其余场景中的内容);回放不受影响,因为派生过程只读取 `assistant/chunk` 事件和第 0 行的会话 header。 +fixture 就是持久化的会话日志(`/session.jsonl`)。其 `assistant/chunk` 事件包含每个 `StreamChunk`,因此按 `(turn, step)` 分组即可重建每次 agent-loop `stream()` 调用的分片序列。压缩(compaction)摘要器成功时,日志记录方式有所不同:当 `compact/summary` 携带完整的 `rawOutput` 时,回放会在该事件的位置重建一条规范成功流,其中每个块各使用一对 `block-start`/`block-end`,带上已记录的 usage(如有),并以 `stop` 终止。提供方增量的精确切分不属于持久压缩结果。不带 `rawOutput` 的摘要并不意味着发生了 LLM 调用,因为模板摘要器和远程摘要器可能不经本地适配器生成该摘要。 + +因此,录制就是「运行一次真实 agent 并收集 `.jsonl`」,由快照 harness 完成;该插件本身不录制。fixture 的 `request/header` 内容可能被标记化为 `{{system}}`/`{{tools}}`(harness 会在一个场景中固定该内容,并清除其余场景中的内容);回放不受影响,因为派生过程只读取 `assistant/chunk` 和 `compact/summary` 事件以及第 0 行的会话 header。 有两种失败模式无法仅根据 `assistant/chunk` 重建:在产生任何分片前直接抛出异常(例如 HTTP 401,此时日志只有 `turn/end {error}` 而没有分片),以及取消或挂起(差异在时序,而非分片内容)。需要这些行为的场景可提供伴随文件(`/replay.override.json`):它可以替换派生脚本(裸 `ReplayEntry[]`),也可以增补派生脚本(`{ patches: [{ at, entry }] }`:保留所有从 JSONL 派生的调用,只替换指定的从 0 开始计数的调用索引;当 `at` 等于派生长度时,则在注入瞬态异常后的重试位置追加一次调用)。补丁索引不得重复。文件加载时会校验覆写文档、每个补丁和条目,以及每个分片的判别标签。`hang` 条目可以指定 `readyFile`;当前缀分片到达循环后、开始等待取消前,回放会写入这个空标记,使外部驱动程序无需观察展示层更新即可确定性地取消。 @@ -57,7 +59,7 @@ fixture 就是持久化的会话日志(`/session.jsonl`)。其 `as - `installLlmReplay(ctx, config)`:安装已配置回放适配器或 catch-all `llm/stream` 监听器;返回 `ReplayHandle`(包含用于保证 HMR(热模块替换)安全的 `dispose()`,以及清理阶段执行的 `assertConsumed()` 检查;后者确保每个已记录脚本都绑定到实时会话,且每个已绑定游标都已耗尽,从而将场景静默驱动的模型调用少于记录数转换为明确诊断)。在测试中使用它,可以不通过 Loader 或 env var 驱动回放。 - `loadSessionScripts(config)`:解析场景中有序的 `SessionScript[]`(主会话 + 子会话),准备按首次调用顺序绑定到实时会话。 - `loadReplayScript(config)`:只解析主会话的 `ReplayEntry[]`(如果伴随文件存在,则使用经校验的替换或补丁;否则从 JSONL 派生;fixture 缺失时明确报错)。 -- `deriveReplayScript(events)` / `parseSessionLog(text)` / `parseSessionHeader(text)` / `resolveScriptedEntry(entry, messages)`:将已记录会话日志转换为脚本、读取其 header `id`/`createdAt`、并针对单次实时请求解析 `{{fromRequest:...}}` 占位符的纯辅助工具。派生分组必须以 `finish` 分片结束;没有该分片的分组是 `stream()` 抛出异常的指纹,必须改用 override 伴随文件表达。 +- `deriveReplayScript(events)` / `parseSessionLog(text)` / `parseSessionHeader(text)` / `resolveScriptedEntry(entry, messages)`:将已记录会话日志中的普通 loop 分片和完整压缩输出转换为脚本、读取其 header `id`/`createdAt`、并针对单次实时请求解析 `{{fromRequest:...}}` 占位符的纯辅助工具。派生的 assistant 分组必须以 `finish` 分片结束;没有该分片的分组是 `stream()` 抛出异常的指纹,必须改用 override 伴随文件表达。 - 类型 `ReplayEntry` / `ReplayOverrideDoc` / `ReplayOverridePatch` / `SessionScript` / `ReplayConfig` / `ReplayProviderConfig` / `ReplayModelConfig` / `ReplayHandle` / `Config`。 ## 插件导出形态 @@ -74,5 +76,5 @@ fixture 就是持久化的会话日志(`/session.jsonl`)。其 `as ## 已知限制与暂缓事项 -- **首次调用顺序脚本绑定假设串行委托**:并发运行同级 subagent 的 cut(或运行中发生的上下文压缩(context compaction)摘要调用)会非确定性地将实时会话绑定到已记录脚本;在这种场景出现前暂不实现更强的键控(`XXX(concurrent-subagents)`)。 -- **只有会产生分片的调用才能派生**:在产生分片前直接抛出异常或取消/挂起的场景需要 `replay.override.json` 伴随文件。替换和补丁两种形式都只影响主会话;子会话脚本仍从各自日志派生。 +- **首次调用顺序脚本绑定假设串行委托**:并发运行同级 subagent 的 cut 会非确定性地将实时会话绑定到已记录脚本;在这种场景出现前暂不实现更强的键控(`XXX(concurrent-subagents)`)。 +- **只有普通 loop 分片和已完成的压缩输出才能派生**:在产生分片前直接抛出异常或取消/挂起的场景需要 `replay.override.json` 伴随文件。替换和补丁两种形式都只影响主会话;子会话脚本仍从各自日志派生。 diff --git a/packages/support/llm-replay/package.json b/packages/support/llm-replay/package.json index 708e84b57a..af5e2929f3 100644 --- a/packages/support/llm-replay/package.json +++ b/packages/support/llm-replay/package.json @@ -25,12 +25,14 @@ ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-compact": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { + "@deepseek-ai/dsh-compact": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/support/llm-replay/src/index.ts b/packages/support/llm-replay/src/index.ts index ae62843492..8733a4296c 100644 --- a/packages/support/llm-replay/src/index.ts +++ b/packages/support/llm-replay/src/index.ts @@ -1,14 +1,16 @@ /** * Keyless snapshot-test LLM replay. It derives one model-call script per - * recorded session from `assistant/chunk` events and binds fresh live sessions - * to parent/child scripts by first-call order. Throw and hang cases require an - * explicit override because a session log cannot reconstruct them alone. + * recorded session from `assistant/chunk` events and durable compaction + * summaries, then binds fresh live sessions to parent/child scripts by + * first-call order. Throw and hang cases require an explicit override because + * a session log cannot reconstruct them alone. * @module @deepseek-ai/dsh-llm-replay */ import { existsSync, readFileSync, writeFileSync } from 'node:fs' import { delimiter as pathDelimiter } from 'node:path' import type { Context } from 'cordis' +import type {} from '@deepseek-ai/dsh-compact' import { decodeStorageRecord } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import type { @@ -24,8 +26,9 @@ import { LlmAdapter, LlmError, assertNever, resolveRetryPolicy } from '@deepseek /** * One recorded model call. `throw` may replay prefix chunks before failing; - * `hang` models cancellation. Only ordinary chunk entries derive from JSONL; - * the other variants come from an override sidecar. + * `hang` models cancellation. Chunk entries derive from ordinary model streams + * and complete compaction outputs in JSONL; the other variants come from an + * override sidecar. */ export type ReplayEntry = | { kind: 'chunks'; chunks: StreamChunk[] } @@ -174,10 +177,12 @@ export function parseSessionHeader(text: string): { id: string; createdAt: numbe * Reconstruct the per-`stream()` replay script from a recorded session log. * * Splits `assistant/chunk` events at every `finish`, using turn and step changes - * to detect an unterminated prior call. A missing terminator means the live - * stream threw, so derivation rejects and the scenario must provide an explicit - * override. Multiple calls may share one turn and step when the loop retries. - * @param events - the recorded session's events; only `assistant/chunk` is consulted. + * to detect an unterminated prior call. A complete `compact/summary.rawOutput` + * becomes a canonical successful stream at the summary's log position. A + * missing assistant terminator means the live stream threw, so derivation + * rejects and the scenario must provide an explicit override. Multiple calls + * may share one turn and step when the loop retries. + * @param events - the recorded session's events. * @returns one `chunks` entry per recorded model call, in call order. */ export function deriveReplayScript(events: SessionEvent[]): ReplayEntry[] { @@ -195,6 +200,22 @@ export function deriveReplayScript(events: SessionEvent[]): ReplayEntry[] { script.push({ kind: 'chunks', chunks }) } for (const event of events) { + if (event.type === 'compact/summary') { + close(currentKey, current) + currentKey = undefined + current = [] + if (event.data.rawOutput !== undefined) { + const chunks: StreamChunk[] = [] + for (const [index, block] of event.data.rawOutput.entries()) { + chunks.push({ type: 'block-start', index, blockType: block.type }) + chunks.push({ type: 'block-end', index, block }) + } + if (event.data.usage !== undefined) chunks.push({ type: 'usage', usage: event.data.usage }) + chunks.push({ type: 'finish', reason: { kind: 'stop' } }) + script.push({ kind: 'chunks', chunks }) + } + continue + } if (event.type !== 'assistant/chunk') continue const { turn, step, chunk } = event.data const key = `${turn}/${step}` diff --git a/packages/support/llm-replay/tests/llm-replay.spec.ts b/packages/support/llm-replay/tests/llm-replay.spec.ts index 0b7d87ad13..9483a4c4f6 100644 --- a/packages/support/llm-replay/tests/llm-replay.spec.ts +++ b/packages/support/llm-replay/tests/llm-replay.spec.ts @@ -178,6 +178,93 @@ describe('deriveReplayScript', () => { expect(deriveReplayScript(events)).toEqual([{ kind: 'chunks', chunks: errChunks }]) }) + it('inserts compact/summary output between the calls surrounding it', () => { + const overflow: StreamChunk[] = [ + { type: 'finish', reason: { kind: 'error', failure: { message: 'too large', code: 'CONTEXT_WINDOW_EXCEEDED' } } }, + ] + const block = { type: 'text' as const, text: 'durable checkpoint' } + const rawOutput = [block] + const usage = { inputTokens: 9, outputTokens: 2 } + const summaryChunks: StreamChunk[] = [ + { type: 'block-start', index: 0, blockType: 'text' }, + { type: 'block-end', index: 0, block }, + { type: 'usage', usage }, + { type: 'finish', reason: { kind: 'stop' } }, + ] + let seq = 1 + const events: SessionEvent[] = [ + ...overflow.map(chunk => chunkEvent(seq++, 1, 2, chunk)), + { type: 'compact/start', seq: seq++, time: 0, data: { turn: 1 } }, + { + type: 'compact/summary', + seq: seq++, + time: 0, + data: { + summary: rawOutput, + rawOutput, + shadowedRange: { start: 1, end: 1 }, + shadowedSeqs: [1], + shadowedTokenCount: 20, + provider: 'mock', + model: 'mock', + usage, + }, + }, + ...TEXT_CHUNKS.map(chunk => chunkEvent(seq++, 1, 2, chunk)), + ] + + expect(deriveReplayScript(events)).toEqual([ + { kind: 'chunks', chunks: overflow }, + { kind: 'chunks', chunks: summaryChunks }, + { kind: 'chunks', chunks: TEXT_CHUNKS }, + ]) + }) + + it('does not infer an LLM call from compact/summary without raw output', () => { + const event: SessionEvent<'compact/summary'> = { + type: 'compact/summary', + seq: 1, + time: 0, + data: { + summary: [{ type: 'text', text: 'template result' }], + shadowedRange: { start: 1, end: 1 }, + shadowedSeqs: [1], + shadowedTokenCount: 20, + provider: 'template', + model: 'template', + }, + } + + expect(deriveReplayScript([event])).toEqual([]) + }) + + it('derives a compact/summary stream when usage is unavailable', () => { + const block = { type: 'text' as const, text: 'summary without usage' } + const event: SessionEvent<'compact/summary'> = { + type: 'compact/summary', + seq: 1, + time: 0, + data: { + summary: [block], + rawOutput: [block], + shadowedRange: { start: 1, end: 1 }, + shadowedSeqs: [1], + shadowedTokenCount: 20, + provider: 'mock', + model: 'mock', + }, + } + + expect(deriveReplayScript([event])).toEqual([{ + kind: 'chunks', + chunks: [ + { type: 'block-start', index: 0, blockType: 'text' }, + { type: 'block-end', index: 0, block }, + { type: 'finish', reason: { kind: 'stop' } }, + ], + }]) + }) + it('throws on a group that lacks a terminal finish chunk (a thrown stream)', () => { // A thrown stream(): prefix chunks logged, then turn/end (error reason), NO finish. const events: SessionEvent[] = [ diff --git a/packages/support/llm-replay/tsconfig.json b/packages/support/llm-replay/tsconfig.json index 673ee51547..b8dc74e792 100644 --- a/packages/support/llm-replay/tsconfig.json +++ b/packages/support/llm-replay/tsconfig.json @@ -14,6 +14,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../../compact/compact" + }, { "path": "../../llm/llm" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c3a3d5ecca..078f775ecf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5959,6 +5959,9 @@ importers: packages/support/llm-replay: devDependencies: + '@deepseek-ai/dsh-compact': + specifier: workspace:^ + version: link:../../compact/compact '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../invariants