From eb51d4c6696927556ab9ad554d173b2e6beeb8a1 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:41:16 +0800 Subject: [PATCH 01/37] fix(acp-snapshot): retain unchanged message ids --- ...table-snapshot-refresh-volatiles.i18n.yaml | 4 +- ...07-27-stable-snapshot-refresh-volatiles.md | 10 +- ...27-stable-snapshot-refresh-volatiles.zh.md | 10 +- .../support/acp-snapshot/README.i18n.yaml | 4 +- packages/support/acp-snapshot/README.md | 2 +- packages/support/acp-snapshot/README.zh.md | 2 +- packages/support/acp-snapshot/src/suite.ts | 112 +++++++++++++-- .../record-suite/rec-child/behavior.json | 6 +- .../record-suite/rec-child/session.1.jsonl | 1 + .../record-suite/rec-child/session.jsonl | 1 + .../support/acp-snapshot/tests/suite.spec.ts | 128 ++++++++++++++++++ 11 files changed, 253 insertions(+), 27 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.i18n.yaml index f2d73ddf1f..28e3accc20 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.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-07-27-stable-snapshot-refresh-volatiles.md -2026-07-27-stable-snapshot-refresh-volatiles.md: e2e951cd9f78b319a701a3e60afba48786633f03 -2026-07-27-stable-snapshot-refresh-volatiles.zh.md: 55302b509e28520f90f6cd820e4962be014318cc +2026-07-27-stable-snapshot-refresh-volatiles.md: 5b513ea026008fc0c4ae9a8045408c534c050bec +2026-07-27-stable-snapshot-refresh-volatiles.zh.md: f3c21d14ae235179ca15ec30d964e02877aa86e9 diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.md b/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.md index e2e951cd9f..5b513ea026 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.md +++ b/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.md @@ -8,9 +8,13 @@ English | [中文](2026-07-27-stable-snapshot-refresh-volatiles.zh.md) ACP snapshot comparison normalizes generated UUIDs, cwd aliases, spill locators, embedded event times, and omitted-byte counts, but refresh write-back persisted the fresh raw values. A behaviorally unchanged refresh therefore rewrote fixtures with new randomness or host-specific path spellings even though the comparison contract considered both logs equal. +Message identity needs a weaker structural precondition than aligned records: an unrelated log event can break record alignment while an inherited message's identity-free value remains unchanged across parent and child logs. Record mode also begins with freshly minted message UUIDs when it replaces an existing fixture. + ## Decision -Refresh write-back uses `normalizeSessionLog` as its sole volatile-value authority. It normalizes the original harvested records with the fresh run's ids, cwd, and every cwd alias, while normalizing fixture records with the fixture header context; literal replacements affect only the raw values being written. After existing record alignment, it recursively compares fresh and existing leaves through those normalized records: normalized-equivalent leaves retain the existing raw value, while normalized-distinct leaves retain the fresh semantic value. +Before record or refresh writes fixtures, the suite fingerprints every complete surface message with its top-level `id` removed and groups occurrences across all parent/child logs. It reuses an existing UUID only when one fingerprint resolves to exactly one fresh ID and one existing ID, then applies that mapping to every fresh log. Repeated inherited occurrences with the same ID remain one candidate, while new, changed, duplicate-content, malformed, and conflicting messages keep their fresh IDs. + +Refresh write-back uses `normalizeSessionLog` as its volatile-value authority for aligned leaves. It normalizes the original harvested records with the fresh run's ids, cwd, and every cwd alias, while normalizing fixture records with the fixture header context; literal replacements affect only the raw values being written. After existing record alignment, it recursively compares fresh and existing leaves through those normalized records: normalized-equivalent leaves retain the existing raw value, while normalized-distinct leaves retain the fresh semantic value. Before reuse, the complete logical-record layout must align, apart from the existing packed-chunk and inserted-title equivalences. Normalized-equivalent changed strings form a log-wide bijection: one fresh string maps to exactly one existing string and vice versa, so repeated IDs remain correlated across records. An unexplained record mismatch or conflicting mapping disables normalized string reuse for that log. @@ -26,6 +30,6 @@ Object fields align by key. Array elements align only when all corresponding arr ## Consequences -Repeated refreshes no longer rewrite aligned fixture values solely because the normalizer classifies them as volatile, and new volatile categories added to the normalizer automatically inherit the write-back behavior. Structural ambiguity remains conservative: unmatched records, conflicting string mappings, resized arrays, and strings containing both semantic and volatile changes use fresh values rather than risk reusing misaligned data. +Record and refresh no longer rewrite an unchanged unique message UUID solely because another event changed the surrounding record layout. Repeated refreshes also retain aligned fixture values that the normalizer classifies as volatile, and new volatile categories added to the normalizer automatically inherit that write-back behavior. Structural ambiguity remains conservative: unmatched records, conflicting string mappings, resized arrays, strings containing both semantic and volatile changes, and non-unique message fingerprints use fresh values rather than risk reusing misaligned data. -Focused unit coverage pins recursive object/array behavior, correlated IDs, ambiguous-layout fallback, conflicting mappings, fresh cwd aliases, volatile strings, and fresh semantic fields. Keyless refresh coverage proves approval UUIDs, cwd aliases, spill paths, and event-read volatility leave their committed fixtures byte-identical. +Focused unit coverage pins scenario-wide parent/child message correlation, unrelated event insertion, record write-back, new/changed/ambiguous messages, recursive object/array behavior, conflicting mappings, fresh cwd aliases, volatile strings, and fresh semantic fields. Keyless refresh coverage proves approval UUIDs, cwd aliases, spill paths, and event-read volatility leave their committed fixtures byte-identical. diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.zh.md b/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.zh.md index 55302b509e..f3c21d14ae 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.zh.md @@ -8,9 +8,13 @@ Status: implemented ACP(Agent Client Protocol)快照比较会归一化生成的 UUID、cwd 别名、spill locator、嵌入的事件时间和省略字节数,但刷新写回会持久化本次生成的原始值。因此,即使比较契约将两份日志视为相等,一次行为未发生变化的刷新仍会用新的随机值或宿主特有的路径写法改写 fixture(测试前置数据)。 +消息身份所需的结构前提比记录对齐更弱:无关的日志事件可能破坏记录对齐,但继承而来的消息去除身份后的值在父级和子级日志之间仍保持不变。录制模式在替换现有 fixture 时也会从新生成的消息 UUID 开始。 + ## 决策 -刷新写回以 `normalizeSessionLog` 作为易变值的唯一判定依据。系统使用本次运行的 id、cwd 及全部 cwd 别名归一化原始收集记录,并使用 fixture header 上下文归一化 fixture 记录;字面量替换只影响要写入的原始值。现有记录完成对齐后,系统基于这些归一化记录,递归比较本次生成记录与现有记录的叶节点:归一化后等价的叶节点保留现有原始值,归一化后不同的叶节点则保留本次生成的语义值。 +在录制或刷新写入 fixture 前,套件会移除每条完整 surface 消息的顶层 `id` 并计算指纹,同时将所有父级/子级日志中的出现项分组。仅当一个指纹恰好对应一个本次生成的 ID 和一个现有 ID 时,才会复用现有 UUID,随后将该映射应用到每份本次生成的日志。具有相同 ID、重复出现的继承消息仍算作一个候选项;新增、发生变化、内容重复、格式错误和存在冲突的消息则保留本次生成的 ID。 + +刷新写回以 `normalizeSessionLog` 作为已对齐叶值的易变值判定依据。系统使用本次运行的 id、cwd 及全部 cwd 别名归一化原始收集记录,并使用 fixture header 上下文归一化 fixture 记录;字面量替换只影响要写入的原始值。现有记录完成对齐后,系统基于这些归一化记录,递归比较本次生成记录与现有记录的叶节点:归一化后等价的叶节点保留现有原始值,归一化后不同的叶节点则保留本次生成的语义值。 复用前必须确保完整逻辑记录布局对齐,现有的打包分片与插入标题等价情形除外。归一化后等价但发生变化的字符串在整份日志范围内形成双射:一个本次生成的字符串只映射到一个现有字符串,反向亦然,因此跨记录重复出现的 ID 仍保持关联。出现无法解释的记录不匹配或映射冲突时,该日志会停用规范化字符串复用。 @@ -26,6 +30,6 @@ ACP(Agent Client Protocol)快照比较会归一化生成的 UUID、cwd 别 ## 后果 -重复刷新不再仅仅因为规范化器将已对齐的 fixture 值归类为易变值,就改写这些值;以后加入规范化器的新易变值类别也会自动继承该写回行为。结构有歧义时仍采取保守策略:记录无法匹配、字符串映射冲突、数组尺寸发生变化,或字符串同时包含语义变化与易变变化时,均使用本次生成的值,避免冒险复用未对齐的数据。 +录制和刷新不再仅仅因为另一个事件改变了周边记录布局,就改写未变化且唯一的消息 UUID。重复刷新也会保留规范化器归类为易变值的已对齐 fixture 值;以后加入规范化器的新易变值类别也会自动继承该写回行为。结构有歧义时仍采取保守策略:记录无法匹配、字符串映射冲突、数组尺寸发生变化、字符串同时包含语义变化与易变变化,或消息指纹不唯一时,均使用本次生成的值,避免冒险复用未对齐的数据。 -聚焦的单元测试固定了递归处理对象与数组的行为、关联 ID、有歧义布局时的回退、映射冲突、本次运行的 cwd 别名、易变字符串以及本次生成的语义字段。无密钥刷新测试证明,审批 UUID、cwd 别名、spill 路径和事件读取中的易变值不会改变已提交 fixture 的任何字节。 +聚焦的单元测试固定了场景范围内的父级/子级消息关联、无关事件插入、录制写回、新增/发生变化/有歧义的消息、递归处理对象与数组的行为、映射冲突、本次运行的 cwd 别名、易变字符串以及本次生成的语义字段。无密钥刷新测试证明,审批 UUID、cwd 别名、spill 路径和事件读取中的易变值不会改变已提交 fixture 的任何字节。 diff --git a/packages/support/acp-snapshot/README.i18n.yaml b/packages/support/acp-snapshot/README.i18n.yaml index 363e0f268c..afabe9147a 100644 --- a/packages/support/acp-snapshot/README.i18n.yaml +++ b/packages/support/acp-snapshot/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/acp-snapshot/README.md -README.md: 948c33a91977f078d16842c285011bf8f83623bd -README.zh.md: fb86bd4e236be1c79f66dc46fbaac4d7dfbf9977 +README.md: e3752dfb522cd55776f3ef796acdc15037e5a761 +README.zh.md: 6ce531640b5311662e4b958177e4417c8878617f diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index 948c33a919..e3752dfb52 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -9,7 +9,7 @@ Four layers, importable separately: - **`launchAcpTestAgent` (launcher)** — boots a source agent under tsx or a built `lib` agent under plain Node from a supplied cwd, connects the SDK client over a raw-byte stdout tee, collects session updates and stderr, surfaces asynchronous spawn failures through startup, fails closed on unhandled permission requests, and owns graceful or signalled shutdown. Shutdown waits for process exit, inherited stdio closure, and ACP parser exhaustion before resolving or propagating a child error, so captures are complete and callers can remove owned paths after either outcome. When Windows accepts forced termination but publishes its exit marker asynchronously, shutdown gives that marker a bounded grace before treating fallback refusal as a second failure. Snapshot and ordinary e2e suites share this process boundary; a test supplies only agent paths, cwd, environment overrides, and any permission policy. - **`runScenario` (harness)** — drives ACP JSON-RPC stdio from a deterministic `input.json` script through the launcher, tees raw stdout for the expected-output and purity checks, and harvests every persisted raw JSONL session log (parent and subagent children, primary-first) after graceful stdin EOF. `AgentUnderTest` supplies absolute `binScript`, optional `libBinScript`, `configPath`, and `tsconfigPath` paths because the subprocess cwd is outside the repo; `workspaceParent` may move the generated child cwd from the platform temp directory when that grant is itself under test. Startup failures preserve captured agent stderr in the rejected diagnostic. - **Normalizers** — pure functions turning captured surfaces into stable text or portable fixtures: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs and every native/JavaScript filesystem spelling of the generated cwd → tokens, longest-first; cwd-rooted separators selected as canonical `/` or host-native; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept, the same cwd-path policy), `tokenizeSessionFixtureCwd` (the generated workspace and its filesystem aliases → `{{cwd}}`, authored temp paths unchanged), `scrubSystemPrompts` (prompt text → `{{system}}`), `scrubToolSchemas` (schema bulk → `{{tools}}`), and `scrubRequestHeaders` (all header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}` outside each pin, structure kept — [pinned-header Agent Note](../../../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). -- **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario expected-output and re-persisted-log comparisons, record/refresh fixture write-back, rejection of structured `UNKNOWN_TOOL` results, a tokenized pin per header class composed with independently shared `system-prompt.expected.md` and `tool-schemas.expected.json` sidecars, and a live uniformity guard. Its fixture guards reject orphan scenario dirs, missing files, multiple pins for one class, duplicate sidecar content, unscrubbed JSONL headers, and malformed pinning headers. Refresh evaluates fresh leaves with the harvested run's ids, cwd, and every cwd alias, then reuses normalized-equivalent leaves only when the complete logical-record layout aligns and volatile string replacements form a bijection; ambiguous logs keep fresh strings, and fresh semantic values remain authoritative. It also expands packed timing envelopes before aligning event times, so switching between packed and unpacked layouts cannot shift later records. A newly inserted `session/title` receives its preceding event's time so feature-driven insertions do not churn the remainder of a fixture. Each scenario directory's `session.jsonl` plus contiguous `session..jsonl` siblings are the ordered primary/child inventory; the scenario table does not duplicate their count. Must be called at vitest collection time. +- **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario expected-output and re-persisted-log comparisons, record/refresh fixture write-back, rejection of structured `UNKNOWN_TOOL` results, a tokenized pin per header class composed with independently shared `system-prompt.expected.md` and `tool-schemas.expected.json` sidecars, and a live uniformity guard. Its fixture guards reject orphan scenario dirs, missing files, multiple pins for one class, duplicate sidecar content, unscrubbed JSONL headers, and malformed pinning headers. Before record or refresh writes fixtures, an unchanged complete message retains its committed UUID when its identity-free value resolves to exactly one fresh ID and one existing ID across the scenario's parent/child logs; new, changed, and ambiguous messages keep fresh UUIDs. Refresh evaluates fresh leaves with the harvested run's ids, cwd, and every cwd alias, then reuses normalized-equivalent leaves only when the complete logical-record layout aligns and volatile string replacements form a bijection; ambiguous logs keep fresh strings, and fresh semantic values remain authoritative. It also expands packed timing envelopes before aligning event times, so switching between packed and unpacked layouts cannot shift later records. A newly inserted `session/title` receives its preceding event's time so feature-driven insertions do not churn the remainder of a fixture. Each scenario directory's `session.jsonl` plus contiguous `session..jsonl` siblings are the ordered primary/child inventory; the scenario table does not duplicate their count. Must be called at vitest collection time. Committed session fixtures use canonical packed rows. An in-flight branch that merges this contract runs the [temporary repository migrator](../../../scripts/migrate-packed-session-fixtures.ts) with `pnpm run migrate:packed-session-fixtures`; its [removal proposal](../../../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md) owns deletion after affected branches converge. diff --git a/packages/support/acp-snapshot/README.zh.md b/packages/support/acp-snapshot/README.zh.md index fb86bd4e23..6ce531640b 100644 --- a/packages/support/acp-snapshot/README.zh.md +++ b/packages/support/acp-snapshot/README.zh.md @@ -9,7 +9,7 @@ ACP 快照套件工具包:无密钥快照层(`pnpm run test:snapshot`,见[ - **`launchAcpTestAgent`(启动器)**:从指定 cwd 在 tsx 下启动源 agent,或在普通 Node 下启动已构建 `lib` agent;通过原始字节 stdout tee 连接 SDK 客户端,收集会话更新和 stderr,在启动过程中公开异步 spawn 失败,对未处理权限请求快速失败,并负责优雅或带信号关闭。关闭会等待进程退出、继承 stdio 关闭和 ACP parser 耗尽,然后才解析或传播子级错误,使捕获内容完整,且调用方可在任一结果后移除自有路径。当 Windows 接受强制终止但异步发布退出标记时,关闭会给该标记有界宽限,然后才将回退拒绝视为第二次失败。快照和普通 e2e 套件共享该进程边界;测试只需提供 agent 路径、cwd、环境覆盖和任何权限策略。 - **`runScenario`(harness)**:通过启动器从确定性 `input.json` 脚本驱动 ACP JSON-RPC stdio,将原始 stdout tee 给预期输出和纯度检查,并在优雅 stdin EOF 后收集每个持久化原始 JSONL 会话日志(父级和 subagent 子级,主级优先)。`AgentUnderTest` 提供绝对 `binScript`、可选 `libBinScript`、`configPath` 和 `tsconfigPath` 路径,因为子进程 cwd 位于仓库外。当生成子级 cwd 自身位于待测授权中时,`workspaceParent` 可以将它从平台临时目录移出。启动失败会在拒绝诊断中保留已捕获 agent stderr。 - **规范化器**:将已捕获接口转换为稳定文本或可移植 fixture 的纯函数:`normalizeStdout`(JSON-RPC id → 首次出现序列;UUID 以及生成 cwd 的每个原生/JavaScript 文件系统写法 → token,按最长优先;根据 cwd 的分隔符选择规范 `/` 或宿主原生形式;同时作为 stdout 纯度检查)、`normalizeSessionLog`(时间归零、保留 `seq`、使用同一 cwd 路径策略)、`tokenizeSessionFixtureCwd`(生成的 workspace 及其文件系统别名 → `{{cwd}}`,手工编写的临时路径保持不变)、`scrubSystemPrompts`(提示词文本 → `{{system}}`)、`scrubToolSchemas`(schema bulk → `{{tools}}`)和 `scrubRequestHeaders`(每个 pin 之外的所有 header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}`,保留结构;见[header 固定 Agent Note](../../../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md))。 -- **`defineAcpSnapshotSuite`(工厂)**:为场景表注册完整 describe/it 树:每场景预期输出与重新持久化日志比较、录制/刷新 fixture 回写、拒绝结构化 `UNKNOWN_TOOL` 结果、每个 header 类别一个 token 化 pin(由可独立共享的 `system-prompt.expected.md` 和 `tool-schemas.expected.json` sidecar 组合而成),以及实时一致性保护。其 fixture 保护会拒绝遗留场景目录、缺失文件、一个类别包含多个 pin、重复的 sidecar 内容、未擦除的 JSONL header,以及格式错误的 pin header。刷新会使用收集所得本次运行的 id、cwd 及全部 cwd 别名评估本次生成的叶值;只有完整逻辑记录布局对齐且易变字符串替换形成双射时,才会复用规范化后等价的叶值;有歧义的日志保留本次生成的字符串,而本次生成的语义值仍为权威数据。它还会在对齐事件时间前展开打包时序 envelope,因此切换打包/非打包布局无法移动后续记录。新插入的 `session/title` 使用前一个事件的时间,因此功能驱动的插入不会扰动 fixture 余下部分。每个场景目录的 `session.jsonl` 和连续 `session..jsonl` 同级文件是有序主级/子级清单;场景表不重复其数量。必须在 vitest 收集时调用。 +- **`defineAcpSnapshotSuite`(工厂)**:为场景表注册完整 describe/it 树:每场景预期输出与重新持久化日志比较、录制/刷新 fixture 回写、拒绝结构化 `UNKNOWN_TOOL` 结果、每个 header 类别一个 token 化 pin(由可独立共享的 `system-prompt.expected.md` 和 `tool-schemas.expected.json` sidecar 组合而成),以及实时一致性保护。其 fixture 保护会拒绝遗留场景目录、缺失文件、一个类别包含多个 pin、重复的 sidecar 内容、未擦除的 JSONL header,以及格式错误的 pin header。在录制或刷新写入 fixture 前,如果一条未变化的完整消息去除身份后的值在场景的父级/子级日志中恰好对应一个本次生成的 ID 和一个现有 ID,它就会保留已提交的 UUID;新增、发生变化和有歧义的消息则保留本次生成的 UUID。刷新会使用收集所得本次运行的 id、cwd 及全部 cwd 别名评估本次生成的叶值;只有完整逻辑记录布局对齐且易变字符串替换形成双射时,才会复用规范化后等价的叶值;有歧义的日志保留本次生成的字符串,而本次生成的语义值仍为权威数据。它还会在对齐事件时间前展开打包时序 envelope,因此切换打包/非打包布局无法移动后续记录。新插入的 `session/title` 使用前一个事件的时间,因此功能驱动的插入不会扰动 fixture 余下部分。每个场景目录的 `session.jsonl` 和连续 `session..jsonl` 同级文件是有序主级/子级清单;场景表不重复其数量。必须在 vitest 收集时调用。 签入仓库的会话 fixture 使用规范打包行。合并此契约的在途分支通过 `pnpm run migrate:packed-session-fixtures` 运行[临时仓库迁移器](../../../scripts/migrate-packed-session-fixtures.ts);待受影响分支收敛后,由其[移除提案](../../../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md)负责删除该迁移器。 diff --git a/packages/support/acp-snapshot/src/suite.ts b/packages/support/acp-snapshot/src/suite.ts index 1a8a49dac5..8a99bc0813 100644 --- a/packages/support/acp-snapshot/src/suite.ts +++ b/packages/support/acp-snapshot/src/suite.ts @@ -48,6 +48,9 @@ const TOOLS_TOKEN = '{{tools}}' const PACKED_CHUNK_ROW_TYPES = new Set(['text-chunks', 'reasoning-chunks', 'tool-call-chunks']) +/** Canonical UUID spelling minted for ordinary message identities. */ +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + /** A snapshot scenario and how its fixtures are produced. */ export interface Scenario { name: string @@ -480,9 +483,9 @@ export function headerChangeCount(rawLog: string): number { .length } -/** A literal string replacement used to carry an existing fixture's volatile value into a refreshed log. */ +/** A literal string replacement used to carry an existing fixture value into fresh write-back. */ export interface FixtureReplacement { - /** The fresh replay-run value to replace. */ + /** The fresh run's value to replace. */ from: string /** The existing fixture value to keep. */ to: string @@ -494,6 +497,82 @@ function parseJsonlRecords(text: string): Record[] { .map(line => JSON.parse(line) as Record) } +/** Return the complete identified message carried by one surface event. */ +function eventMessage(record: Record): Record | undefined { + const data = record.data + if (!isRecord(data)) return undefined + const message = record.type === 'user/message' + ? data + : record.type === 'assistant/message' || record.type === 'tool/result' || record.type === 'steering/message' + ? data.message + : undefined + if ( + !isRecord(message) + || typeof message.id !== 'string' + || !UUID_RE.test(message.id) + || typeof message.role !== 'string' + || !Array.isArray(message.content) + || !isRecord(message.source) + ) return undefined + return message +} + +/** Serialize parsed JSON by value rather than insertion order. */ +function canonicalJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]` + if (isRecord(value)) { + return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(',')}}` + } + return JSON.stringify(value) +} + +/** Index each unambiguous identity-free message value by its sole message id. */ +function uniqueMessageIds(logs: readonly string[]): Map { + const fingerprintsById = new Map() + for (const log of logs) { + for (const record of parseJsonlRecords(log)) { + const message = eventMessage(record) + if (message === undefined) continue + const { id, ...withoutId } = message + const messageId = id as string + const fingerprint = canonicalJson(withoutId) + if (!fingerprintsById.has(messageId)) fingerprintsById.set(messageId, fingerprint) + else if (fingerprintsById.get(messageId) !== fingerprint) fingerprintsById.set(messageId, undefined) + } + } + + const idsByFingerprint = new Map() + for (const [id, fingerprint] of fingerprintsById) { + if (fingerprint === undefined) continue + if (!idsByFingerprint.has(fingerprint)) idsByFingerprint.set(fingerprint, id) + else idsByFingerprint.set(fingerprint, undefined) + } + return idsByFingerprint +} + +/** + * Match unchanged complete messages across a scenario's fresh and existing logs. + * New, changed, repeated, or otherwise ambiguous messages keep their fresh ids. + */ +function fixtureMessageIdReplacements(logs: HarvestedLog[], fixtures: string[]): FixtureReplacement[] { + const freshIds = uniqueMessageIds(logs.map(log => log.content)) + const existingIds = uniqueMessageIds(fixtures) + const replacements: FixtureReplacement[] = [] + for (const [fingerprint, fresh] of freshIds) { + const existing = existingIds.get(fingerprint) + if (fresh === undefined || existing === undefined || fresh === existing) continue + replacements.push({ from: fresh, to: existing }) + } + return replacements +} + +/** Apply literal fixture replacements without changing any other fresh value. */ +function applyFixtureReplacements(content: string, replacements: readonly FixtureReplacement[]): string { + let stable = content + for (const { from, to } of replacements) stable = stable.split(from).join(to) + return stable +} + /** One packed row's member times, or `undefined` for an ordinary record. */ function packedTimes(record: Record): number[] | undefined { if (!PACKED_CHUNK_ROW_TYPES.has(record.type as string)) return undefined @@ -539,14 +618,15 @@ export function unknownToolCallIds(rawLog: string): string[] { } /** - * Build the cross-log id/cwd/spill-path replacements used by refresh write-back. + * Build refresh write-back replacements: scenario-wide unchanged message ids, + * plus per-log session ids, cwd values, and spill paths. * * @param logs The freshly harvested logs, in fixture order. * @param fixtures The existing fixture contents, in matching order. - * @returns Literal replacements from fresh volatile values to the fixture's old values. + * @returns Literal replacements from fresh values to the fixture's existing values. */ export function refreshFixtureReplacements(logs: HarvestedLog[], fixtures: string[]): FixtureReplacement[] { - const replacements: FixtureReplacement[] = [] + const replacements = fixtureMessageIdReplacements(logs, fixtures) for (let i = 0; i < logs.length; i++) { const fresh = parseJsonlRecords((logs[i] as HarvestedLog).content)[0] const existing = parseJsonlRecords(fixtures[i] ?? '')[0] @@ -823,8 +903,7 @@ export function stabilizeRefreshLog( freshContext: NormalizeContext, ): string { const freshRecords = parseJsonlRecords(fresh) - let stable = fresh - for (const { from, to } of replacements) stable = stable.split(from).join(to) + const stable = applyFixtureReplacements(fresh, replacements) const existingRecords = logicalRecords(parseJsonlRecords(existing)) const records = parseJsonlRecords(stable) const existingContext = fixtureContext(existing) @@ -1016,10 +1095,6 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { const portableFixture = scenario.workspaceParent === undefined ? tokenizeSessionFixtureCwd : (log: string): string => log - const existingFixtures = REFRESHING - ? await Promise.all(fixtureFiles.map(file => readFile(join(dir, file), 'utf8'))) - : [] - const replacements = REFRESHING ? refreshFixtureReplacements(result.sessionLogs, existingFixtures) : [] const writesSessionFixtures = (RECORDING && scenario.recorded && scenario.hasModelTurn) || (REFRESHING && comparesLog) if (writesSessionFixtures) { @@ -1032,14 +1107,25 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { 'session.jsonl', ...Array.from({ length: result.sessionLogs.length - 1 }, (_, i) => `session.${i + 1}.jsonl`), ] + const existingFixtures = await Promise.all(outputFixtureFiles.map(async (file) => { + const path = join(dir, file) + return existsSync(path) ? readFile(path, 'utf8') : '' + })) + const replacements = REFRESHING + ? refreshFixtureReplacements(result.sessionLogs, existingFixtures) + : fixtureMessageIdReplacements(result.sessionLogs, existingFixtures) const primary = (result.sessionLogs[0] as HarvestedLog).content await writeFile(join(dir, outputFixtureFiles[0] as string), scrub(portableFixture( - REFRESHING ? stabilizeRefreshLog(primary, existingFixtures[0] as string, replacements, ctx) : primary, + REFRESHING + ? stabilizeRefreshLog(primary, existingFixtures[0] as string, replacements, ctx) + : applyFixtureReplacements(primary, replacements), ))) for (let i = 1; i < result.sessionLogs.length; i++) { const child = (result.sessionLogs[i] as HarvestedLog).content await writeFile(join(dir, outputFixtureFiles[i] as string), scrub(portableFixture( - REFRESHING ? stabilizeRefreshLog(child, existingFixtures[i] as string, replacements, ctx) : child, + REFRESHING + ? stabilizeRefreshLog(child, existingFixtures[i] as string, replacements, ctx) + : applyFixtureReplacements(child, replacements), ))) } if (RECORDING) { diff --git a/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-child/behavior.json b/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-child/behavior.json index d98afb4865..971006c139 100644 --- a/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-child/behavior.json +++ b/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-child/behavior.json @@ -3,11 +3,13 @@ "logs": [ { "file": "b/parent/session.jsonl", "lines": [ { "type": "session", "id": "{{SID}}", "createdAt": 700, "cwd": "{{CWD}}", "delegationDepth": 0 }, - { "type": "request/header", "seq": 0, "time": 3, "data": { "header": { "config": { "model": "fake" }, "system": "SYS PROMPT", "tools": [{ "name": "t1", "description": "D1", "parameters": { "type": "object" } }] }, "reason": "initial" } } + { "type": "request/header", "seq": 0, "time": 3, "data": { "header": { "config": { "model": "fake" }, "system": "SYS PROMPT", "tools": [{ "name": "t1", "description": "D1", "parameters": { "type": "object" } }] }, "reason": "initial" } }, + { "type": "user/message", "seq": 1, "time": 5, "data": { "role": "user", "content": [{ "type": "text", "text": "same inherited message" }], "source": { "kind": "user" }, "id": "11111111-1111-4111-8111-111111111111" }, "surfaceOp": "append" } ]}, { "file": "b/child/session.jsonl", "lines": [ { "type": "session", "id": "abababab-cdcd-4efe-8ada-badabadabada", "createdAt": 800, "cwd": "{{CWD}}", "parentSession": "{{SID}}", "delegationDepth": 1 }, - { "type": "request/header", "seq": 0, "time": 2, "data": { "header": { "config": { "model": "fake" }, "system": "SYS PROMPT", "tools": [{ "name": "t1", "description": "D1", "parameters": { "type": "object" } }] }, "reason": "initial" } } + { "type": "request/header", "seq": 0, "time": 2, "data": { "header": { "config": { "model": "fake" }, "system": "SYS PROMPT", "tools": [{ "name": "t1", "description": "D1", "parameters": { "type": "object" } }] }, "reason": "initial" } }, + { "type": "user/message", "seq": 1, "time": 5, "data": { "role": "user", "content": [{ "type": "text", "text": "same inherited message" }], "source": { "kind": "user" }, "id": "11111111-1111-4111-8111-111111111111" }, "surfaceOp": "append" } ]} ] } diff --git a/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-child/session.1.jsonl b/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-child/session.1.jsonl index 4fa81014ae..384b3954cf 100644 --- a/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-child/session.1.jsonl +++ b/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-child/session.1.jsonl @@ -1,2 +1,3 @@ {"type":"session","id":"abababab-cdcd-4efe-8ada-badabadabada","createdAt":800,"cwd":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-KBQJbW","parentSession":"f6fa7fcf-dd9c-4b39-8815-b25ddcebfd88","delegationDepth":1} {"type":"request/header","seq":0,"time":2,"data":{"header":{"config":{"model":"fake"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"user/message","seq":1,"time":5,"data":{"role":"user","content":[{"type":"text","text":"same inherited message"}],"source":{"kind":"user"},"id":"22222222-2222-4222-8222-222222222222"},"surfaceOp":"append"} diff --git a/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-child/session.jsonl b/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-child/session.jsonl index e972a78d8e..0ffe7f9f5f 100644 --- a/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-child/session.jsonl +++ b/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-child/session.jsonl @@ -1,2 +1,3 @@ {"type":"session","id":"f6fa7fcf-dd9c-4b39-8815-b25ddcebfd88","createdAt":700,"cwd":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-KBQJbW","delegationDepth":0} {"type":"request/header","seq":0,"time":3,"data":{"header":{"config":{"model":"fake"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"user/message","seq":1,"time":5,"data":{"role":"user","content":[{"type":"text","text":"same inherited message"}],"source":{"kind":"user"},"id":"22222222-2222-4222-8222-222222222222"},"surfaceOp":"append"} diff --git a/packages/support/acp-snapshot/tests/suite.spec.ts b/packages/support/acp-snapshot/tests/suite.spec.ts index da069905ec..2cd665f540 100644 --- a/packages/support/acp-snapshot/tests/suite.spec.ts +++ b/packages/support/acp-snapshot/tests/suite.spec.ts @@ -193,6 +193,18 @@ describe('defineAcpSnapshotSuite: record inventory write-back', () => { expect(fixture).toContain('"cwd":"{{cwd}}"') expect(() => readFileSync(join(recordDir, 'rec-child', 'session.2.jsonl'), 'utf8')).toThrow() }) + + it('retains an unchanged message id across the recorded parent and child fixtures', () => { + const existingMessageId = '22222222-2222-4222-8222-222222222222' + const freshMessageId = '11111111-1111-4111-8111-111111111111' + const fixtures = ['session.jsonl', 'session.1.jsonl'] + .map(file => readFileSync(join(recordDir, 'rec-child', file), 'utf8')) + + for (const fixture of fixtures) { + expect(fixture).toContain(`"id":"${existingMessageId}"`) + expect(fixture).not.toContain(freshMessageId) + } + }) }) describe('defineAcpSnapshotSuite: registration contract', () => { @@ -663,6 +675,78 @@ describe('refreshFixtureReplacements', () => { { from: freshBash, to: oldBash }, ]) }) + + it('maps one inherited message id across parent and child logs', () => { + const freshMessageId = '11111111-1111-4111-8111-111111111111' + const existingMessageId = '22222222-2222-4222-8222-222222222222' + const content = [{ type: 'text', text: 'inherited' }] + const log = (sessionId: string, messageId: string): string => [ + JSON.stringify({ type: 'session', id: sessionId, cwd: '/same' }), + JSON.stringify({ + type: 'user/message', + data: { role: 'user', content, source: { kind: 'user' }, id: messageId }, + }), + '', + ].join('\n') + const harvested = (content: string): HarvestedLog => ({ id: 'diagnostic', createdAt: 1, content }) + + const replacements = refreshFixtureReplacements( + [harvested(log('fresh-parent', freshMessageId)), harvested(log('fresh-child', freshMessageId))], + [log('old-parent', existingMessageId), log('old-child', existingMessageId)], + ) + + expect(replacements.filter(replacement => replacement.from === freshMessageId)).toEqual([ + { from: freshMessageId, to: existingMessageId }, + ]) + }) + + it('keeps fresh ids for new, changed, and ambiguous messages', () => { + const ids = { + new: '11111111-1111-4111-8111-111111111111', + changed: '22222222-2222-4222-8222-222222222222', + ambiguousA: '33333333-3333-4333-8333-333333333333', + ambiguousB: '44444444-4444-4444-8444-444444444444', + oldChanged: '55555555-5555-4555-8555-555555555555', + oldAmbiguous: '66666666-6666-4666-8666-666666666666', + stable: '77777777-7777-4777-8777-777777777777', + } as const + const message = (id: string, text: string): Record => ({ + type: 'user/message', + data: { role: 'user', content: [{ type: 'text', text }], source: { kind: 'user' }, id }, + }) + const log = (messages: Record[]): string => [ + JSON.stringify({ type: 'session', id: 'same', cwd: '/same' }), + ...messages.map(record => JSON.stringify(record)), + '', + ].join('\n') + const fresh = log([ + message(ids.new, 'new'), + message(ids.changed, 'changed'), + message(ids.changed, 'changed again'), + message(ids.ambiguousA, 'duplicate'), + message(ids.ambiguousB, 'duplicate'), + message(ids.stable, 'stable'), + ]) + const existing = log([ + message(ids.oldChanged, 'before'), + message(ids.oldAmbiguous, 'duplicate'), + message(ids.stable, 'stable'), + ]) + + const replacements = refreshFixtureReplacements( + [{ id: 'diagnostic', createdAt: 1, content: fresh }], + [existing], + ) + + const replacedIds = replacements.map(replacement => replacement.from) + for (const id of [ + ids.new, + ids.changed, + ids.ambiguousA, + ids.ambiguousB, + ids.stable, + ]) expect(replacedIds).not.toContain(id) + }) }) describe('stabilizeRefreshLog', () => { @@ -765,6 +849,50 @@ describe('stabilizeRefreshLog', () => { ].join('\n')) }) + it('retains unchanged message ids across an unrelated inserted event', () => { + const freshUserId = '11111111-1111-4111-8111-111111111111' + const existingUserId = '22222222-2222-4222-8222-222222222222' + const freshAssistantId = '33333333-3333-4333-8333-333333333333' + const existingAssistantId = '44444444-4444-4444-8444-444444444444' + const user = (id: string): Record => ({ + type: 'user/message', + data: { role: 'user', content: [{ type: 'text', text: 'same user' }], source: { kind: 'user' }, id }, + }) + const assistant = (id: string): Record => ({ + type: 'assistant/message', + data: { + turn: 1, + step: 1, + message: { + role: 'assistant', + content: [{ type: 'text', text: 'same assistant' }], + source: { kind: 'model', provider: 'fake', model: 'fake' }, + id, + }, + }, + }) + const lines = (records: Record[]): string => [ + JSON.stringify({ type: 'session', id: 'same', createdAt: 1, cwd: '/same' }), + ...records.map(record => JSON.stringify(record)), + '', + ].join('\n') + const fresh = lines([ + user(freshUserId), + { type: 'session/inherited', data: {} }, + assistant(freshAssistantId), + ]) + const existing = lines([user(existingUserId), assistant(existingAssistantId)]) + const replacements = refreshFixtureReplacements( + [{ id: 'diagnostic', createdAt: 1, content: fresh }], + [existing], + ) + const output = stabilize(fresh, existing, replacements).trim().split('\n') + .map(line => JSON.parse(line) as Record) + + expect((output[1]?.data as { id: string }).id).toBe(existingUserId) + expect(((output[3]?.data as { message: { id: string } }).message).id).toBe(existingAssistantId) + }) + it('keeps volatile fixture fields while preserving fresh meaningful payloads', () => { const fresh = [ '{"type":"session","id":"new-child","createdAt":200,"cwd":"/new","parentSession":"new-parent","seedLength":1}', From 466a2f12c3196c532ea6fde0918805daf67d87a8 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:51:07 +0800 Subject: [PATCH 02/37] fix(snapshot): stabilize all recorder message ids --- ...table-snapshot-refresh-volatiles.i18n.yaml | 4 +- ...07-27-stable-snapshot-refresh-volatiles.md | 4 +- ...27-stable-snapshot-refresh-volatiles.zh.md | 4 +- apps/web/tests/scaffold.ts | 9 ++-- examples/jsonrpc-agent/tests/sdk.snapshot.ts | 15 ++++-- examples/tui-agent/tests/tui.snapshot.ts | 25 +++++----- .../support/acp-snapshot/README.i18n.yaml | 4 +- packages/support/acp-snapshot/README.md | 4 +- packages/support/acp-snapshot/README.zh.md | 4 +- packages/support/acp-snapshot/src/index.ts | 1 + packages/support/acp-snapshot/src/suite.ts | 49 ++++++++++++------- .../support/acp-snapshot/tests/suite.spec.ts | 37 +++++++++++++- 12 files changed, 110 insertions(+), 50 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.i18n.yaml index 28e3accc20..3a474e377b 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.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-07-27-stable-snapshot-refresh-volatiles.md -2026-07-27-stable-snapshot-refresh-volatiles.md: 5b513ea026008fc0c4ae9a8045408c534c050bec -2026-07-27-stable-snapshot-refresh-volatiles.zh.md: f3c21d14ae235179ca15ec30d964e02877aa86e9 +2026-07-27-stable-snapshot-refresh-volatiles.md: cd806c929ba956098f532d19159ff2dc3e782325 +2026-07-27-stable-snapshot-refresh-volatiles.zh.md: 3144bcb45aa8524e29fae3d07479cb917987ea79 diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.md b/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.md index 5b513ea026..cd806c929b 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.md +++ b/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.md @@ -12,7 +12,7 @@ Message identity needs a weaker structural precondition than aligned records: an ## Decision -Before record or refresh writes fixtures, the suite fingerprints every complete surface message with its top-level `id` removed and groups occurrences across all parent/child logs. It reuses an existing UUID only when one fingerprint resolves to exactly one fresh ID and one existing ID, then applies that mapping to every fresh log. Repeated inherited occurrences with the same ID remain one candidate, while new, changed, duplicate-content, malformed, and conflicting messages keep their fresh IDs. +Before record or refresh writes session fixtures, the shared snapshot support fingerprints every complete surface message with its top-level `id` removed and groups occurrences across all parent/child logs. It reuses an existing UUID only when one fingerprint resolves to exactly one fresh ID and one existing ID, then applies that mapping to every fresh log. Repeated inherited occurrences with the same ID remain one candidate, while new, changed, duplicate-content, malformed, and conflicting messages keep their fresh IDs. ACP, JSON-RPC, TUI, and web recorders pass fixture-ready logs through the same helper before writing. Refresh write-back uses `normalizeSessionLog` as its volatile-value authority for aligned leaves. It normalizes the original harvested records with the fresh run's ids, cwd, and every cwd alias, while normalizing fixture records with the fixture header context; literal replacements affect only the raw values being written. After existing record alignment, it recursively compares fresh and existing leaves through those normalized records: normalized-equivalent leaves retain the existing raw value, while normalized-distinct leaves retain the fresh semantic value. @@ -30,6 +30,6 @@ Object fields align by key. Array elements align only when all corresponding arr ## Consequences -Record and refresh no longer rewrite an unchanged unique message UUID solely because another event changed the surrounding record layout. Repeated refreshes also retain aligned fixture values that the normalizer classifies as volatile, and new volatile categories added to the normalizer automatically inherit that write-back behavior. Structural ambiguity remains conservative: unmatched records, conflicting string mappings, resized arrays, strings containing both semantic and volatile changes, and non-unique message fingerprints use fresh values rather than risk reusing misaligned data. +Record and refresh no longer rewrite an unchanged unique message UUID solely because another event changed the surrounding record layout, regardless of whether ACP, JSON-RPC, TUI, or web owns the recording. Repeated refreshes also retain aligned fixture values that the normalizer classifies as volatile, and new volatile categories added to the normalizer automatically inherit that write-back behavior. Structural ambiguity remains conservative: unmatched records, conflicting string mappings, resized arrays, strings containing both semantic and volatile changes, and non-unique message fingerprints use fresh values rather than risk reusing misaligned data. Focused unit coverage pins scenario-wide parent/child message correlation, unrelated event insertion, record write-back, new/changed/ambiguous messages, recursive object/array behavior, conflicting mappings, fresh cwd aliases, volatile strings, and fresh semantic fields. Keyless refresh coverage proves approval UUIDs, cwd aliases, spill paths, and event-read volatility leave their committed fixtures byte-identical. diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.zh.md b/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.zh.md index f3c21d14ae..3144bcb45a 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.zh.md @@ -12,7 +12,7 @@ ACP(Agent Client Protocol)快照比较会归一化生成的 UUID、cwd 别 ## 决策 -在录制或刷新写入 fixture 前,套件会移除每条完整 surface 消息的顶层 `id` 并计算指纹,同时将所有父级/子级日志中的出现项分组。仅当一个指纹恰好对应一个本次生成的 ID 和一个现有 ID 时,才会复用现有 UUID,随后将该映射应用到每份本次生成的日志。具有相同 ID、重复出现的继承消息仍算作一个候选项;新增、发生变化、内容重复、格式错误和存在冲突的消息则保留本次生成的 ID。 +在录制或刷新写入会话 fixture 前,共享快照支持层会移除每条完整 surface 消息的顶层 `id` 并计算指纹,同时将所有父级/子级日志中的出现项分组。仅当一个指纹恰好对应一个本次生成的 ID 和一个现有 ID 时,才会复用现有 UUID,随后将该映射应用到每份本次生成的日志。具有相同 ID、重复出现的继承消息仍算作一个候选项;新增、发生变化、内容重复、格式错误和存在冲突的消息则保留本次生成的 ID。ACP、JSON-RPC、TUI 和 web 录制器都会先让可写入 fixture 的日志经过同一个辅助函数,再执行写入。 刷新写回以 `normalizeSessionLog` 作为已对齐叶值的易变值判定依据。系统使用本次运行的 id、cwd 及全部 cwd 别名归一化原始收集记录,并使用 fixture header 上下文归一化 fixture 记录;字面量替换只影响要写入的原始值。现有记录完成对齐后,系统基于这些归一化记录,递归比较本次生成记录与现有记录的叶节点:归一化后等价的叶节点保留现有原始值,归一化后不同的叶节点则保留本次生成的语义值。 @@ -30,6 +30,6 @@ ACP(Agent Client Protocol)快照比较会归一化生成的 UUID、cwd 别 ## 后果 -录制和刷新不再仅仅因为另一个事件改变了周边记录布局,就改写未变化且唯一的消息 UUID。重复刷新也会保留规范化器归类为易变值的已对齐 fixture 值;以后加入规范化器的新易变值类别也会自动继承该写回行为。结构有歧义时仍采取保守策略:记录无法匹配、字符串映射冲突、数组尺寸发生变化、字符串同时包含语义变化与易变变化,或消息指纹不唯一时,均使用本次生成的值,避免冒险复用未对齐的数据。 +录制和刷新不再仅仅因为另一个事件改变了周边记录布局,就改写未变化且唯一的消息 UUID,无论该录制由 ACP、JSON-RPC、TUI 还是 web 负责。重复刷新也会保留规范化器归类为易变值的已对齐 fixture 值;以后加入规范化器的新易变值类别也会自动继承该写回行为。结构有歧义时仍采取保守策略:记录无法匹配、字符串映射冲突、数组尺寸发生变化、字符串同时包含语义变化与易变变化,或消息指纹不唯一时,均使用本次生成的值,避免冒险复用未对齐的数据。 聚焦的单元测试固定了场景范围内的父级/子级消息关联、无关事件插入、录制写回、新增/发生变化/有歧义的消息、递归处理对象与数组的行为、映射冲突、本次运行的 cwd 别名、易变字符串以及本次生成的语义字段。无密钥刷新测试证明,审批 UUID、cwd 别名、spill 路径和事件读取中的易变值不会改变已提交 fixture 的任何字节。 diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 753cdc2953..d11604c075 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -27,7 +27,7 @@ import { expect } from 'vitest' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import Include, { type PatchOptions } from '@cordisjs/plugin-include' -import { scrubRequestHeaders } from '@deepseek-ai/dsh-acp-snapshot' +import { scrubRequestHeaders, stabilizeFixtureMessageIds } from '@deepseek-ai/dsh-acp-snapshot' import { assertEntriesLoaded } from '@deepseek-ai/dsh-app-boot' import type { ReplayHandle } from '@deepseek-ai/dsh-llm-replay' import { installLlmReplay, parseSessionLog } from '@deepseek-ai/dsh-llm-replay' @@ -302,11 +302,14 @@ function rawSessionLog(session: Session): string { export async function recordFixture(scaffold: WebScaffold, sessionId: SessionId, fixturePath: string): Promise { const agent = scaffold.ctx.agents.get(sessionId) if (agent === undefined) throw new Error(`record harvest: no live agent for ${sessionId}`) - const tokenized = scrubRequestHeaders(rawSessionLog(agent.session)) + const fresh = scrubRequestHeaders(rawSessionLog(agent.session)) .split(sessionId).join('{{sessionId}}') .split(scaffold.workspaceCwd).join('{{cwd}}') .replace(/"rpcId":"[^"]+"/g, '"rpcId":"{{rpcId}}"') - await writeFile(fixturePath, tokenized) + const existing = existsSync(fixturePath) ? await readFile(fixturePath, 'utf8') : '' + const stable = stabilizeFixtureMessageIds([fresh], [existing])[0] + if (stable === undefined) throw new Error('record harvest: no stabilized fixture') + await writeFile(fixturePath, stable) } /** diff --git a/examples/jsonrpc-agent/tests/sdk.snapshot.ts b/examples/jsonrpc-agent/tests/sdk.snapshot.ts index c54812e3e5..3f0edac743 100644 --- a/examples/jsonrpc-agent/tests/sdk.snapshot.ts +++ b/examples/jsonrpc-agent/tests/sdk.snapshot.ts @@ -9,6 +9,7 @@ * fixtures and rewrites expected outputs. */ +import { existsSync } from 'node:fs' import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { delimiter, join } from 'node:path' @@ -19,6 +20,7 @@ import { normalizeStdout, refreshFixtureReplacements, scrubRequestHeaders, + stabilizeFixtureMessageIds, stabilizeRefreshLog, tokenizeSessionFixtureCwd, type HarvestedLog, @@ -230,20 +232,25 @@ describe('TypeScript SDK snapshots over the jsonrpc runtime', () => { const { result, notifications, logs, cwd } = await runScenario(scenario) const ordered = orderLogs(logs, scenario) const actualContext = contextOf(ordered, cwd) + const files = fixtureFiles(scenario) if (recording) { // Fixtures carry tokenized request headers; llm-replay reads only // assistant output and tool traffic, so scrubbing keeps prompts and // schemas out of the corpus without affecting replay. await mkdir(scenarioDir, { recursive: true }) - await Promise.all(ordered.map(async (log, index) => { - const file = fixtureFiles(scenario)[index] + const existing = await Promise.all(files.map(async file => existsSync(file) ? readFile(file, 'utf8') : '')) + const fixtures = stabilizeFixtureMessageIds( + ordered.map(log => scrubRequestHeaders(tokenizeSessionFixtureCwd(log.content))), + existing, + ) + await Promise.all(fixtures.map(async (fixture, index) => { + const file = files[index] if (file === undefined) throw new Error(`no fixture path for persisted log ${index}`) - await writeFile(file, scrubRequestHeaders(tokenizeSessionFixtureCwd(log.content))) + await writeFile(file, fixture) })) } - const files = fixtureFiles(scenario) let expectedContents = await Promise.all(files.map(file => readFile(file, 'utf8'))) if (refreshing) { diff --git a/examples/tui-agent/tests/tui.snapshot.ts b/examples/tui-agent/tests/tui.snapshot.ts index 95b960e3b6..934621b037 100644 --- a/examples/tui-agent/tests/tui.snapshot.ts +++ b/examples/tui-agent/tests/tui.snapshot.ts @@ -1,10 +1,15 @@ +import { existsSync } from 'node:fs' import { cp, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path' import { fileURLToPath } from 'node:url' import { afterAll, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import { scrubRequestHeaders, tokenizeSessionFixtureCwd } from '@deepseek-ai/dsh-acp-snapshot' +import { + scrubRequestHeaders, + stabilizeFixtureMessageIds, + tokenizeSessionFixtureCwd, +} from '@deepseek-ai/dsh-acp-snapshot' import type { Agent } from '@deepseek-ai/dsh-agent' import * as AgentCore from '@deepseek-ai/dsh-agent-spine-demo' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' @@ -484,17 +489,15 @@ async function runScenario(scenario: Scenario): Promise { async function writeRecording(scenario: Scenario, result: ScenarioResult): Promise { const dir = scenarioDir(scenario) await mkdir(dir, { recursive: true }) - await writeFile( - join(dir, 'session.jsonl'), - scrubRequestHeaders(tokenizeSessionFixtureCwd(rawSessionLog(result.parent))), - ) expect(result.children).toHaveLength(scenario.childSessions ?? 0) - for (const [index, child] of result.children.entries()) { - await writeFile( - join(dir, `session.${index + 1}.jsonl`), - scrubRequestHeaders(tokenizeSessionFixtureCwd(rawSessionLog(child))), - ) - } + const files = [join(dir, 'session.jsonl'), ...childFixturePaths(scenario)] + const existing = await Promise.all(files.map(async file => existsSync(file) ? readFile(file, 'utf8') : '')) + const fixtures = stabilizeFixtureMessageIds( + [result.parent, ...result.children] + .map(session => scrubRequestHeaders(tokenizeSessionFixtureCwd(rawSessionLog(session)))), + existing, + ) + await Promise.all(fixtures.map((fixture, index) => writeFile(files[index] as string, fixture))) } describe('TUI recorded-session terminal snapshots', () => { diff --git a/packages/support/acp-snapshot/README.i18n.yaml b/packages/support/acp-snapshot/README.i18n.yaml index afabe9147a..f90ce7ada8 100644 --- a/packages/support/acp-snapshot/README.i18n.yaml +++ b/packages/support/acp-snapshot/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/acp-snapshot/README.md -README.md: e3752dfb522cd55776f3ef796acdc15037e5a761 -README.zh.md: 6ce531640b5311662e4b958177e4417c8878617f +README.md: c5a1a07b9f85e1a91c52fe102be17e4172be112d +README.zh.md: 2454df9b3d8e67b4728d6582279ba21798d2ba9e diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index e3752dfb52..c5a1a07b9f 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -8,7 +8,7 @@ Four layers, importable separately: - **`launchAcpTestAgent` (launcher)** — boots a source agent under tsx or a built `lib` agent under plain Node from a supplied cwd, connects the SDK client over a raw-byte stdout tee, collects session updates and stderr, surfaces asynchronous spawn failures through startup, fails closed on unhandled permission requests, and owns graceful or signalled shutdown. Shutdown waits for process exit, inherited stdio closure, and ACP parser exhaustion before resolving or propagating a child error, so captures are complete and callers can remove owned paths after either outcome. When Windows accepts forced termination but publishes its exit marker asynchronously, shutdown gives that marker a bounded grace before treating fallback refusal as a second failure. Snapshot and ordinary e2e suites share this process boundary; a test supplies only agent paths, cwd, environment overrides, and any permission policy. - **`runScenario` (harness)** — drives ACP JSON-RPC stdio from a deterministic `input.json` script through the launcher, tees raw stdout for the expected-output and purity checks, and harvests every persisted raw JSONL session log (parent and subagent children, primary-first) after graceful stdin EOF. `AgentUnderTest` supplies absolute `binScript`, optional `libBinScript`, `configPath`, and `tsconfigPath` paths because the subprocess cwd is outside the repo; `workspaceParent` may move the generated child cwd from the platform temp directory when that grant is itself under test. Startup failures preserve captured agent stderr in the rejected diagnostic. -- **Normalizers** — pure functions turning captured surfaces into stable text or portable fixtures: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs and every native/JavaScript filesystem spelling of the generated cwd → tokens, longest-first; cwd-rooted separators selected as canonical `/` or host-native; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept, the same cwd-path policy), `tokenizeSessionFixtureCwd` (the generated workspace and its filesystem aliases → `{{cwd}}`, authored temp paths unchanged), `scrubSystemPrompts` (prompt text → `{{system}}`), `scrubToolSchemas` (schema bulk → `{{tools}}`), and `scrubRequestHeaders` (all header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}` outside each pin, structure kept — [pinned-header Agent Note](../../../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). +- **Normalizers** — pure functions turning captured surfaces into stable text or portable fixtures: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs and every native/JavaScript filesystem spelling of the generated cwd → tokens, longest-first; cwd-rooted separators selected as canonical `/` or host-native; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept, the same cwd-path policy), `tokenizeSessionFixtureCwd` (the generated workspace and its filesystem aliases → `{{cwd}}`, authored temp paths unchanged), `scrubSystemPrompts` (prompt text → `{{system}}`), `scrubToolSchemas` (schema bulk → `{{tools}}`), `scrubRequestHeaders` (all header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}` outside each pin, structure kept — [pinned-header Agent Note](../../../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)), and `stabilizeFixtureMessageIds` (committed UUIDs carried into unchanged, unambiguous messages across any recorder's fixture-ready parent/child logs). - **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario expected-output and re-persisted-log comparisons, record/refresh fixture write-back, rejection of structured `UNKNOWN_TOOL` results, a tokenized pin per header class composed with independently shared `system-prompt.expected.md` and `tool-schemas.expected.json` sidecars, and a live uniformity guard. Its fixture guards reject orphan scenario dirs, missing files, multiple pins for one class, duplicate sidecar content, unscrubbed JSONL headers, and malformed pinning headers. Before record or refresh writes fixtures, an unchanged complete message retains its committed UUID when its identity-free value resolves to exactly one fresh ID and one existing ID across the scenario's parent/child logs; new, changed, and ambiguous messages keep fresh UUIDs. Refresh evaluates fresh leaves with the harvested run's ids, cwd, and every cwd alias, then reuses normalized-equivalent leaves only when the complete logical-record layout aligns and volatile string replacements form a bijection; ambiguous logs keep fresh strings, and fresh semantic values remain authoritative. It also expands packed timing envelopes before aligning event times, so switching between packed and unpacked layouts cannot shift later records. A newly inserted `session/title` receives its preceding event's time so feature-driven insertions do not churn the remainder of a fixture. Each scenario directory's `session.jsonl` plus contiguous `session..jsonl` siblings are the ordered primary/child inventory; the scenario table does not duplicate their count. Must be called at vitest collection time. Committed session fixtures use canonical packed rows. An in-flight branch that merges this contract runs the [temporary repository migrator](../../../scripts/migrate-packed-session-fixtures.ts) with `pnpm run migrate:packed-session-fixtures`; its [removal proposal](../../../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md) owns deletion after affected branches converge. @@ -59,7 +59,7 @@ Every scenario compares `stdout.expected.jsonl` with cwd-rooted separators canon The example also ships a `cordis.snapshot.yml` replay overlay next to its `cordis.yml` (the bin swaps them under `DSH_SNAPSHOT=replay` — [single-source replay config Agent Note](../../../.agents/notes/archived/testing/2026-07-04-single-source-acp-replay-config.md)); replay fixtures are served by [`dsh-llm-replay`](../llm-replay/README.md), which this package points at via the `DSH_SNAPSHOT_*` env vars it sets on the child. `pnpm run test:snapshot:record` calls the live LLM and rewrites the recorded scenarios' model fixtures; `pnpm run test:snapshot:refresh` stays keyless, runs the replay overlay, and rewrites stdout, comparable session-log expected outputs, and owned prompt and tool-schema sidecars from the committed model scripts. Fixture roles, record/replay/refresh semantics, and scenario-table fields are documented on `Scenario` and in the [snapshot Agent Note](../../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md). -Constraints: `suite.ts` and `harness.ts` import vitest (the harness polls its durable-boundary waits through `vi.waitFor`), so the package entry is importable only inside a vitest run (the launcher and normalizers have no such dependency but ship from the same entry). The launcher and suite factory are ACP-specific by design — the launcher speaks the SDK's `ClientSideConnection` — while the normalizers are transport-neutral session-log/text helpers also consumed by the TUI snapshot suite and the web browser e2e lane. Input scripts cover initialization, fresh-session creation, text prompting, cancellation, expected RPC failures, and durable turn-boundary waits. Permission round-trips are a FIFO queue of option-kind selections (`allow_once`, `reject_once`, …) mapped to the agent-issued `optionId`; an absent or exhausted queue answers `cancelled`, and an unoffered kind rejects the run. +Constraints: `suite.ts` and `harness.ts` import vitest (the harness polls its durable-boundary waits through `vi.waitFor`), so the package entry is importable only inside a vitest run (the launcher and normalizers have no such dependency but ship from the same entry). The launcher and suite factory are ACP-specific by design — the launcher speaks the SDK's `ClientSideConnection` — while the normalizers are transport-neutral session-log/text helpers also consumed by the JSON-RPC, TUI, and web snapshot recorders. Input scripts cover initialization, fresh-session creation, text prompting, cancellation, expected RPC failures, and durable turn-boundary waits. Permission round-trips are a FIFO queue of option-kind selections (`allow_once`, `reject_once`, …) mapped to the agent-issued `optionId`; an absent or exhausted queue answers `cancelled`, and an unoffered kind rejects the run. ## Model Experience diff --git a/packages/support/acp-snapshot/README.zh.md b/packages/support/acp-snapshot/README.zh.md index 6ce531640b..2454df9b3d 100644 --- a/packages/support/acp-snapshot/README.zh.md +++ b/packages/support/acp-snapshot/README.zh.md @@ -8,7 +8,7 @@ ACP 快照套件工具包:无密钥快照层(`pnpm run test:snapshot`,见[ - **`launchAcpTestAgent`(启动器)**:从指定 cwd 在 tsx 下启动源 agent,或在普通 Node 下启动已构建 `lib` agent;通过原始字节 stdout tee 连接 SDK 客户端,收集会话更新和 stderr,在启动过程中公开异步 spawn 失败,对未处理权限请求快速失败,并负责优雅或带信号关闭。关闭会等待进程退出、继承 stdio 关闭和 ACP parser 耗尽,然后才解析或传播子级错误,使捕获内容完整,且调用方可在任一结果后移除自有路径。当 Windows 接受强制终止但异步发布退出标记时,关闭会给该标记有界宽限,然后才将回退拒绝视为第二次失败。快照和普通 e2e 套件共享该进程边界;测试只需提供 agent 路径、cwd、环境覆盖和任何权限策略。 - **`runScenario`(harness)**:通过启动器从确定性 `input.json` 脚本驱动 ACP JSON-RPC stdio,将原始 stdout tee 给预期输出和纯度检查,并在优雅 stdin EOF 后收集每个持久化原始 JSONL 会话日志(父级和 subagent 子级,主级优先)。`AgentUnderTest` 提供绝对 `binScript`、可选 `libBinScript`、`configPath` 和 `tsconfigPath` 路径,因为子进程 cwd 位于仓库外。当生成子级 cwd 自身位于待测授权中时,`workspaceParent` 可以将它从平台临时目录移出。启动失败会在拒绝诊断中保留已捕获 agent stderr。 -- **规范化器**:将已捕获接口转换为稳定文本或可移植 fixture 的纯函数:`normalizeStdout`(JSON-RPC id → 首次出现序列;UUID 以及生成 cwd 的每个原生/JavaScript 文件系统写法 → token,按最长优先;根据 cwd 的分隔符选择规范 `/` 或宿主原生形式;同时作为 stdout 纯度检查)、`normalizeSessionLog`(时间归零、保留 `seq`、使用同一 cwd 路径策略)、`tokenizeSessionFixtureCwd`(生成的 workspace 及其文件系统别名 → `{{cwd}}`,手工编写的临时路径保持不变)、`scrubSystemPrompts`(提示词文本 → `{{system}}`)、`scrubToolSchemas`(schema bulk → `{{tools}}`)和 `scrubRequestHeaders`(每个 pin 之外的所有 header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}`,保留结构;见[header 固定 Agent Note](../../../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md))。 +- **规范化器**:将已捕获接口转换为稳定文本或可移植 fixture 的纯函数:`normalizeStdout`(JSON-RPC id → 首次出现序列;UUID 以及生成 cwd 的每个原生/JavaScript 文件系统写法 → token,按最长优先;根据 cwd 的分隔符选择规范 `/` 或宿主原生形式;同时作为 stdout 纯度检查)、`normalizeSessionLog`(时间归零、保留 `seq`、使用同一 cwd 路径策略)、`tokenizeSessionFixtureCwd`(生成的 workspace 及其文件系统别名 → `{{cwd}}`,手工编写的临时路径保持不变)、`scrubSystemPrompts`(提示词文本 → `{{system}}`)、`scrubToolSchemas`(schema bulk → `{{tools}}`)、`scrubRequestHeaders`(每个 pin 之外的所有 header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}`,保留结构;见[header 固定 Agent Note](../../../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md))和 `stabilizeFixtureMessageIds`(针对任意录制器已准备写入 fixture 的父级/子级日志,将已提交 UUID 带入未变化且无歧义的消息)。 - **`defineAcpSnapshotSuite`(工厂)**:为场景表注册完整 describe/it 树:每场景预期输出与重新持久化日志比较、录制/刷新 fixture 回写、拒绝结构化 `UNKNOWN_TOOL` 结果、每个 header 类别一个 token 化 pin(由可独立共享的 `system-prompt.expected.md` 和 `tool-schemas.expected.json` sidecar 组合而成),以及实时一致性保护。其 fixture 保护会拒绝遗留场景目录、缺失文件、一个类别包含多个 pin、重复的 sidecar 内容、未擦除的 JSONL header,以及格式错误的 pin header。在录制或刷新写入 fixture 前,如果一条未变化的完整消息去除身份后的值在场景的父级/子级日志中恰好对应一个本次生成的 ID 和一个现有 ID,它就会保留已提交的 UUID;新增、发生变化和有歧义的消息则保留本次生成的 UUID。刷新会使用收集所得本次运行的 id、cwd 及全部 cwd 别名评估本次生成的叶值;只有完整逻辑记录布局对齐且易变字符串替换形成双射时,才会复用规范化后等价的叶值;有歧义的日志保留本次生成的字符串,而本次生成的语义值仍为权威数据。它还会在对齐事件时间前展开打包时序 envelope,因此切换打包/非打包布局无法移动后续记录。新插入的 `session/title` 使用前一个事件的时间,因此功能驱动的插入不会扰动 fixture 余下部分。每个场景目录的 `session.jsonl` 和连续 `session..jsonl` 同级文件是有序主级/子级清单;场景表不重复其数量。必须在 vitest 收集时调用。 签入仓库的会话 fixture 使用规范打包行。合并此契约的在途分支通过 `pnpm run migrate:packed-session-fixtures` 运行[临时仓库迁移器](../../../scripts/migrate-packed-session-fixtures.ts);待受影响分支收敛后,由其[移除提案](../../../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md)负责删除该迁移器。 @@ -59,7 +59,7 @@ defineAcpSnapshotSuite({ 示例还发布 `cordis.snapshot.yml` 回放 overlay,位于 `cordis.yml` 旁边(bin 在 `DSH_SNAPSHOT=replay` 下交换它们,见[单源回放配置 Agent Note](../../../.agents/notes/archived/testing/2026-07-04-single-source-acp-replay-config.md));回放 fixture 由 [`dsh-llm-replay`](../llm-replay/README.md) 提供,该包通过对子级设置的 `DSH_SNAPSHOT_*` env var 指向它。`pnpm run test:snapshot:record` 调用实时 LLM,并重写已记录场景的模型 fixture;`pnpm run test:snapshot:refresh` 保持无密钥,运行回放 overlay,并从已提交模型脚本重写 stdout、可比较会话日志预期输出,以及各 pin 自有的提示词与工具 schema sidecar。Fixture 角色、录制/回放/刷新语义和场景表字段记录在 `Scenario` 以及[快照 Agent Note](../../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md) 中。 -约束:`suite.ts` 与 `harness.ts` 导入 vitest(harness 通过 `vi.waitFor` 轮询其持久边界等待),因此包入口只能在 vitest 运行中导入(启动器和规范化器没有此依赖,但从同一入口发布)。启动器和套件工厂按设计专用于 ACP,启动器使用 SDK 的 `ClientSideConnection`;规范化器是与传输无关的会话日志/文本辅助工具,还由 TUI 快照套件和 web 浏览器 e2e lane 消费。输入脚本覆盖初始化、新建会话、文本提示、取消、预期 RPC 失败和持久轮次边界等待。权限往返是选项类别选择(`allow_once`、`reject_once`等)的 FIFO 队列,映射到 agent 发出的 `optionId`;缺少或耗尽的队列回答 `cancelled`,未提供类别会拒绝运行。 +约束:`suite.ts` 与 `harness.ts` 导入 vitest(harness 通过 `vi.waitFor` 轮询其持久边界等待),因此包入口只能在 vitest 运行中导入(启动器和规范化器没有此依赖,但从同一入口发布)。启动器和套件工厂按设计专用于 ACP,启动器使用 SDK 的 `ClientSideConnection`;规范化器是与传输无关的会话日志/文本辅助工具,还由 JSON-RPC、TUI 和 web 快照录制器消费。输入脚本覆盖初始化、新建会话、文本提示、取消、预期 RPC 失败和持久轮次边界等待。权限往返是选项类别选择(`allow_once`、`reject_once`等)的 FIFO 队列,映射到 agent 发出的 `optionId`;缺少或耗尽的队列回答 `cancelled`,未提供类别会拒绝运行。 ## 模型体验 diff --git a/packages/support/acp-snapshot/src/index.ts b/packages/support/acp-snapshot/src/index.ts index 09d05031c7..6d8f5c0953 100644 --- a/packages/support/acp-snapshot/src/index.ts +++ b/packages/support/acp-snapshot/src/index.ts @@ -46,6 +46,7 @@ export { export { defineAcpSnapshotSuite, refreshFixtureReplacements, + stabilizeFixtureMessageIds, stabilizeRefreshLog, type Scenario, type SnapshotSuiteOptions, diff --git a/packages/support/acp-snapshot/src/suite.ts b/packages/support/acp-snapshot/src/suite.ts index 8a99bc0813..315095cc10 100644 --- a/packages/support/acp-snapshot/src/suite.ts +++ b/packages/support/acp-snapshot/src/suite.ts @@ -554,8 +554,8 @@ function uniqueMessageIds(logs: readonly string[]): Map log.content)) +function fixtureMessageIdReplacements(logs: readonly string[], fixtures: readonly string[]): FixtureReplacement[] { + const freshIds = uniqueMessageIds(logs) const existingIds = uniqueMessageIds(fixtures) const replacements: FixtureReplacement[] = [] for (const [fingerprint, fresh] of freshIds) { @@ -573,6 +573,18 @@ function applyFixtureReplacements(content: string, replacements: readonly Fixtur return stable } +/** + * Carry committed UUIDs into unchanged, unambiguous messages in fresh session fixtures. + * + * @param logs Fresh fixture-ready session JSONL contents for one scenario. + * @param fixtures Existing fixture contents in matching order; missing fixtures may be empty strings. + * @returns The fresh contents with only reusable message UUIDs replaced. + */ +export function stabilizeFixtureMessageIds(logs: readonly string[], fixtures: readonly string[]): string[] { + const replacements = fixtureMessageIdReplacements(logs, fixtures) + return logs.map(log => applyFixtureReplacements(log, replacements)) +} + /** One packed row's member times, or `undefined` for an ordinary record. */ function packedTimes(record: Record): number[] | undefined { if (!PACKED_CHUNK_ROW_TYPES.has(record.type as string)) return undefined @@ -626,7 +638,7 @@ export function unknownToolCallIds(rawLog: string): string[] { * @returns Literal replacements from fresh values to the fixture's existing values. */ export function refreshFixtureReplacements(logs: HarvestedLog[], fixtures: string[]): FixtureReplacement[] { - const replacements = fixtureMessageIdReplacements(logs, fixtures) + const replacements = fixtureMessageIdReplacements(logs.map(log => log.content), fixtures) for (let i = 0; i < logs.length; i++) { const fresh = parseJsonlRecords((logs[i] as HarvestedLog).content)[0] const existing = parseJsonlRecords(fixtures[i] ?? '')[0] @@ -1111,23 +1123,22 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { const path = join(dir, file) return existsSync(path) ? readFile(path, 'utf8') : '' })) - const replacements = REFRESHING + const refreshReplacements = REFRESHING ? refreshFixtureReplacements(result.sessionLogs, existingFixtures) - : fixtureMessageIdReplacements(result.sessionLogs, existingFixtures) - const primary = (result.sessionLogs[0] as HarvestedLog).content - await writeFile(join(dir, outputFixtureFiles[0] as string), scrub(portableFixture( - REFRESHING - ? stabilizeRefreshLog(primary, existingFixtures[0] as string, replacements, ctx) - : applyFixtureReplacements(primary, replacements), - ))) - for (let i = 1; i < result.sessionLogs.length; i++) { - const child = (result.sessionLogs[i] as HarvestedLog).content - await writeFile(join(dir, outputFixtureFiles[i] as string), scrub(portableFixture( - REFRESHING - ? stabilizeRefreshLog(child, existingFixtures[i] as string, replacements, ctx) - : applyFixtureReplacements(child, replacements), - ))) - } + : [] + const outputFixtures = REFRESHING + ? result.sessionLogs.map((log, index) => scrub(portableFixture(stabilizeRefreshLog( + log.content, + existingFixtures[index] as string, + refreshReplacements, + ctx, + )))) + : stabilizeFixtureMessageIds( + result.sessionLogs.map(log => scrub(portableFixture(log.content))), + existingFixtures, + ) + await Promise.all(outputFixtures.map((fixture, index) => + writeFile(join(dir, outputFixtureFiles[index] as string), fixture))) if (RECORDING) { const outputNames = new Set(outputFixtureFiles) const entries = await readdir(dir, { withFileTypes: true }) diff --git a/packages/support/acp-snapshot/tests/suite.spec.ts b/packages/support/acp-snapshot/tests/suite.spec.ts index 2cd665f540..e2e4129a5b 100644 --- a/packages/support/acp-snapshot/tests/suite.spec.ts +++ b/packages/support/acp-snapshot/tests/suite.spec.ts @@ -4,7 +4,12 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { afterAll, describe, expect, it } from 'vitest' -import { defineAcpSnapshotSuite, type HarvestedLog, type Scenario } from '../src/index.ts' +import { + defineAcpSnapshotSuite, + stabilizeFixtureMessageIds, + type HarvestedLog, + type Scenario, +} from '../src/index.ts' import { assertUniqueSnapshotContents, claimSharedSnapshot, @@ -635,6 +640,36 @@ describe('unknownToolCallIds', () => { }) }) +describe('stabilizeFixtureMessageIds', () => { + it('reuses one committed message UUID across fixture-ready parent and child logs', () => { + const freshId = '11111111-1111-4111-8111-111111111111' + const existingId = '22222222-2222-4222-8222-222222222222' + const log = (session: string, id: string): string => [ + JSON.stringify({ type: 'session', id: session, cwd: '{{cwd}}' }), + JSON.stringify({ + type: 'user/message', + data: { role: 'user', content: [{ type: 'text', text: 'same' }], source: { kind: 'user' }, id }, + }), + '', + ].join('\n') + const fresh = [log('fresh-parent', freshId), log('fresh-child', freshId)] + const existing = [log('old-parent', existingId), log('old-child', existingId)] + + const stable = stabilizeFixtureMessageIds(fresh, existing) + + expect(stable).toHaveLength(2) + for (const fixture of stable) { + expect(fixture).toContain(`"id":"${existingId}"`) + expect(fixture).not.toContain(freshId) + } + }) + + it('leaves fresh fixtures unchanged when no committed counterpart exists', () => { + const fresh = '{"type":"session","id":"new"}\n' + expect(stabilizeFixtureMessageIds([fresh], [''])).toEqual([fresh]) + }) +}) + describe('refreshFixtureReplacements', () => { it('maps fresh ids and cwd values to the existing fixture values, skipping non-replacements', () => { const log = (content: string): HarvestedLog => ({ id: 'diagnostic', createdAt: 1, content }) From 88c035c98e2992641d390bd083be400da5d7d3c2 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 4 Aug 2026 14:11:38 +0800 Subject: [PATCH 03/37] 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 04/37] 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 05/37] 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 06/37] 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 07/37] 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 08/37] 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 09/37] 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 10/37] 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 11/37] 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 12/37] 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 13/37] 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 14/37] 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 15/37] 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 16/37] 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 17/37] 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 18/37] 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 19/37] 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 20/37] 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 21/37] 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 22/37] 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 23/37] 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 24/37] 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 25/37] 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 26/37] 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 27/37] 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 28/37] 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 29/37] 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 From 8982d714cb2bc362af06cd1274afc1acb667c891 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 02:15:44 +0800 Subject: [PATCH 30/37] fix(snapshot): harden message id retention --- ...table-snapshot-refresh-volatiles.i18n.yaml | 4 +- ...07-27-stable-snapshot-refresh-volatiles.md | 8 +- ...27-stable-snapshot-refresh-volatiles.zh.md | 8 +- examples/jsonrpc-agent/tests/sdk.snapshot.ts | 13 +- .../support/acp-snapshot/README.i18n.yaml | 4 +- packages/support/acp-snapshot/README.md | 4 +- packages/support/acp-snapshot/README.zh.md | 4 +- packages/support/acp-snapshot/package.json | 2 + packages/support/acp-snapshot/src/suite.ts | 162 +++++++++----- .../support/acp-snapshot/tests/suite.spec.ts | 199 ++++++++++++------ packages/support/acp-snapshot/tsconfig.json | 3 + pnpm-lock.yaml | 3 + 12 files changed, 283 insertions(+), 131 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.i18n.yaml index e322fba1dd..0820c01b7f 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.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-07-27-stable-snapshot-refresh-volatiles.md -2026-07-27-stable-snapshot-refresh-volatiles.md: a0613357c698934f91598bdf53da983b1dd53f08 -2026-07-27-stable-snapshot-refresh-volatiles.zh.md: 303a4d6a4cc9f2d45448359c0e48677228a0c1f9 +2026-07-27-stable-snapshot-refresh-volatiles.md: c3eeeca01a7820b5f410bd895de998e944e58eb2 +2026-07-27-stable-snapshot-refresh-volatiles.zh.md: 388b67c67052074fa7423eae294e00b4122b2fe8 diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.md b/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.md index a0613357c6..c3eeeca01a 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.md +++ b/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.md @@ -12,9 +12,9 @@ Message identity needs a weaker structural precondition than aligned records: an ## Decision -Before record or refresh writes session fixtures, the shared snapshot support fingerprints every complete surface message with its top-level `id` removed and groups occurrences across all parent/child logs. It reuses an existing UUID only when one fingerprint resolves to exactly one fresh ID and one existing ID, then applies that mapping to every fresh log. Repeated inherited occurrences with the same ID remain one candidate, while new, changed, duplicate-content, malformed, and conflicting messages keep their fresh IDs. ACP, JSON-RPC, and Web recorders pass fixture-ready logs through the same helper before writing. +Before record or refresh writes session fixtures, the shared snapshot support passes fixture-ready logs to one structural message-ID owner. It recognizes surface carriers through the session package's authoritative surface-type predicate and the correlated queued copies in `agent/inbox/spliced`, fingerprints every complete message with its top-level `id` removed, and records every ID-to-fingerprint edge across all parent/child logs. It reuses an existing UUID only when both its ID and fingerprint have degree one in the fresh and existing graphs, then rewrites only validated message `id` fields in those carriers. Repeated inherited occurrences with the same ID remain one candidate, while new, changed, duplicate-content, malformed, and conflicting messages keep their fresh IDs. ACP, JSON-RPC, and Web recorders run this pass after header scrubbing and cwd tokenization, so fixture spellings rather than raw host paths determine identity. -Refresh write-back uses `normalizeSessionLog` as its volatile-value authority for aligned leaves. It normalizes the original harvested records with the fresh run's ids, cwd, and every cwd alias, while normalizing fixture records with the fixture header context; literal replacements affect only the raw values being written. After existing record alignment, it recursively compares fresh and existing leaves through those normalized records: normalized-equivalent leaves retain the existing raw value, while normalized-distinct leaves retain the fresh semantic value. +Refresh write-back uses `normalizeSessionLog` as its volatile-value authority for aligned leaves. It normalizes the original harvested records with the fresh run's ids, cwd, and every cwd alias, while normalizing fixture records with the fixture header context; literal replacements are limited to fresh-run session IDs, cwd values, and spill paths. After existing record alignment, it recursively compares fresh and existing leaves through those normalized records: normalized-equivalent leaves retain the existing raw value, while normalized-distinct leaves retain the fresh semantic value. Complete message IDs in surface or inbox carriers are excluded from this path so positional reuse and structural reuse cannot assign the same committed UUID independently. Before reuse, the complete logical-record layout must align, apart from the existing packed-chunk and inserted-title equivalences. Normalized-equivalent changed strings form a log-wide bijection: one fresh string maps to exactly one existing string and vice versa, so repeated IDs remain correlated across records. An unexplained record mismatch or conflicting mapping disables normalized string reuse for that log. @@ -30,6 +30,6 @@ Object fields align by key. Array elements align only when all corresponding arr ## Consequences -Record and refresh no longer rewrite an unchanged unique message UUID solely because another event changed the surrounding record layout, regardless of whether ACP, JSON-RPC, or Web owns the recording. Repeated refreshes also retain aligned fixture values that the normalizer classifies as volatile, and new volatile categories added to the normalizer automatically inherit that write-back behavior. Structural ambiguity remains conservative: unmatched records, conflicting string mappings, resized arrays, strings containing both semantic and volatile changes, and non-unique message fingerprints use fresh values rather than risk reusing misaligned data. +Record and refresh no longer rewrite an unchanged unique message UUID solely because another event changed the surrounding record layout, regardless of whether ACP, JSON-RPC, or Web owns the recording. Repeated refreshes also retain aligned fixture values that the normalizer classifies as volatile, and new volatile categories added to the normalizer automatically inherit that write-back behavior. Structural ambiguity remains conservative: unmatched records, conflicting string mappings, resized arrays, strings containing both semantic and volatile changes, malformed messages, and any message graph with a non-unique ID or fingerprint use fresh values rather than risk reusing misaligned data. -Focused unit coverage pins scenario-wide parent/child message correlation, unrelated event insertion, record write-back, new/changed/ambiguous messages, recursive object/array behavior, conflicting mappings, fresh cwd aliases, volatile strings, and fresh semantic fields. Keyless refresh coverage proves approval UUIDs, cwd aliases, spill paths, and event-read volatility leave their committed fixtures byte-identical. +Focused unit coverage pins all authoritative surface-message shapes, durable inbox/surface correlation, scenario-wide parent/child correlation, cwd-bearing fixture-ready matching, unrelated event insertion, malformed-message isolation, both-axis graph ambiguity, single-owner write-back, recursive object/array behavior, conflicting mappings, fresh cwd aliases, volatile strings, and fresh semantic fields. Keyless refresh coverage proves approval UUIDs, cwd aliases, spill paths, and event-read volatility leave their committed fixtures byte-identical. diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.zh.md b/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.zh.md index 303a4d6a4c..388b67c670 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.zh.md @@ -12,9 +12,9 @@ ACP(Agent Client Protocol)快照比较会归一化生成的 UUID、cwd 别 ## 决策 -在录制或刷新写入会话 fixture 前,共享快照支持层会移除每条完整 surface 消息的顶层 `id` 并计算指纹,同时将所有父级/子级日志中的出现项分组。仅当一个指纹恰好对应一个本次生成的 ID 和一个现有 ID 时,才会复用现有 UUID,随后将该映射应用到每份本次生成的日志。具有相同 ID、重复出现的继承消息仍算作一个候选项;新增、发生变化、内容重复、格式错误和存在冲突的消息则保留本次生成的 ID。ACP、JSON-RPC 和 Web 录制器都会先让可写入 fixture 的日志经过同一个辅助函数,再执行写入。 +在录制或刷新写入会话 fixture 前,共享快照支持层会将可写入 fixture 的日志交给一个负责结构化处理消息 ID 的组件。该组件通过会话包的权威 surface 类型谓词识别 surface 载体,并识别 `agent/inbox/spliced` 中与这些载体关联的已排队消息副本;随后移除每条完整消息的顶层 `id` 并计算指纹,同时记录所有父级/子级日志中每条 ID 与指纹之间的关联边。仅当该 ID 与指纹在本次生成图和现有图中的度均为 1 时,才会复用现有 UUID,随后仅改写这些载体中通过验证的消息 `id` 字段。具有相同 ID、重复出现的继承消息仍算作一个候选项;新增、发生变化、内容重复、格式错误和存在冲突的消息则保留本次生成的 ID。ACP、JSON-RPC 和 Web 录制器会在擦除 header 并对 cwd 进行 token 化后执行这一步,因此消息身份取决于 fixture 中的写法,而非宿主机原始路径。 -刷新写回以 `normalizeSessionLog` 作为已对齐叶值的易变值判定依据。系统使用本次运行的 id、cwd 及全部 cwd 别名归一化原始收集记录,并使用 fixture 头部上下文归一化 fixture 记录;字面量替换只影响要写入的原始值。现有记录完成对齐后,系统基于这些归一化记录,递归比较本次生成记录与现有记录的叶节点:归一化后等价的叶节点保留现有原始值,归一化后不同的叶节点则保留本次生成的语义值。 +刷新写回以 `normalizeSessionLog` 作为已对齐叶值的易变值判定依据。系统使用本次运行的 id、cwd 及全部 cwd 别名归一化原始收集记录,并使用 fixture 头部上下文归一化 fixture 记录;字面量替换仅限于本次运行生成的会话 ID、cwd 值和 spill 路径。现有记录完成对齐后,系统基于这些归一化记录,递归比较本次生成记录与现有记录的叶节点:归一化后等价的叶节点保留现有原始值,归一化后不同的叶节点则保留本次生成的语义值。surface 或 inbox 载体中的完整消息 ID 不参与这一路径,以免按位置复用与结构复用各自独立分配同一个已提交 UUID。 复用前必须确保完整逻辑记录布局对齐,现有的打包分片与插入标题等价情形除外。归一化后等价但发生变化的字符串在整份日志范围内形成双射:一个本次生成的字符串只映射到一个现有字符串,反向亦然,因此跨记录重复出现的 ID 仍保持关联。出现无法解释的记录不匹配或映射冲突时,该日志会停用归一化字符串复用。 @@ -30,6 +30,6 @@ ACP(Agent Client Protocol)快照比较会归一化生成的 UUID、cwd 别 ## 后果 -录制和刷新不再仅仅因为另一个事件改变了周边记录布局,就改写未变化且唯一的消息 UUID,无论该录制由 ACP、JSON-RPC 还是 Web 负责。重复刷新也会保留规范化器归类为易变值的已对齐 fixture 值;以后加入规范化器的新易变值类别也会自动继承该写回行为。结构有歧义时仍采取保守策略:记录无法匹配、字符串映射冲突、数组尺寸发生变化、字符串同时包含语义变化与易变变化,或消息指纹不唯一时,均使用本次生成的值,避免冒险复用未对齐的数据。 +录制和刷新不再仅仅因为另一个事件改变了周边记录布局,就改写未变化且唯一的消息 UUID,无论该录制由 ACP、JSON-RPC 还是 Web 负责。重复刷新也会保留规范化器归类为易变值的已对齐 fixture 值;以后加入规范化器的新易变值类别也会自动继承该写回行为。结构有歧义时仍采取保守策略:记录无法匹配、字符串映射冲突、数组尺寸发生变化、字符串同时包含语义变化与易变变化、消息格式错误,或消息图中的 ID 或指纹不唯一时,均使用本次生成的值,避免冒险复用未对齐的数据。 -聚焦的单元测试固定了场景范围内的父级/子级消息关联、无关事件插入、录制写回、新增/发生变化/有歧义的消息、递归处理对象与数组的行为、映射冲突、本次运行的 cwd 别名、易变字符串以及本次生成的语义字段。无密钥刷新测试证明,审批 UUID、cwd 别名、spill 路径和事件读取中的易变值不会改变已提交 fixture 的任何字节。 +聚焦的单元测试固定了会话包权威谓词识别的所有 surface 消息形态、持久 inbox/surface 关联、场景范围内的父级/子级消息关联、带 cwd 的可写入 fixture 消息匹配、无关事件插入、格式错误消息隔离、消息图在 ID 与指纹两条轴上的歧义、由单一处理方负责的写回、递归处理对象与数组的行为、映射冲突、本次运行的 cwd 别名、易变字符串以及本次生成的语义字段。无密钥刷新测试证明,审批 UUID、cwd 别名、spill 路径和事件读取中的易变值不会改变已提交 fixture 的任何字节。 diff --git a/examples/jsonrpc-agent/tests/sdk.snapshot.ts b/examples/jsonrpc-agent/tests/sdk.snapshot.ts index 1f4335402e..4a29d6eb6a 100644 --- a/examples/jsonrpc-agent/tests/sdk.snapshot.ts +++ b/examples/jsonrpc-agent/tests/sdk.snapshot.ts @@ -350,15 +350,18 @@ describe('TypeScript SDK snapshots over the jsonrpc runtime', () => { content: log.content, })) const replacements = refreshFixtureReplacements(harvested, expectedContents) - expectedContents = await Promise.all(ordered.map(async (log, index) => { + const refreshed = ordered.map((log, index) => { const existing = expectedContents[index] - const file = files[index] - if (existing === undefined || file === undefined) throw new Error(`no fixture for persisted log ${index}`) - const stable = scrubRequestHeaders(tokenizeSessionFixtureCwd( + if (existing === undefined) throw new Error(`no fixture for persisted log ${index}`) + return scrubRequestHeaders(tokenizeSessionFixtureCwd( stabilizeRefreshLog(log.content, existing, replacements, actualContext), )) + }) + expectedContents = stabilizeFixtureMessageIds(refreshed, expectedContents) + await Promise.all(expectedContents.map(async (stable, index) => { + const file = files[index] + if (file === undefined) throw new Error(`no fixture for persisted log ${index}`) await writeFile(file, stable) - return stable })) } diff --git a/packages/support/acp-snapshot/README.i18n.yaml b/packages/support/acp-snapshot/README.i18n.yaml index bed064e909..25b54630b2 100644 --- a/packages/support/acp-snapshot/README.i18n.yaml +++ b/packages/support/acp-snapshot/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/acp-snapshot/README.md -README.md: 9d142dc964e60f9508b6c137525eb916cdbd969f -README.zh.md: 2e88e7c5bb8acb0cd99a35b5fe0fbfc15d6101e3 +README.md: 0b935ef60c33fd24660d8ecf2497f5506157c724 +README.zh.md: 91be3c97bcb67ce10c61513113f683e741bc762f diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index 9d142dc964..0b935ef60c 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -8,8 +8,8 @@ Four layers, importable separately: - **`launchAcpTestAgent` (launcher)** — boots a source agent under tsx or a built `lib` agent under plain Node from a supplied cwd, connects the SDK client over a raw-byte stdout tee, collects session updates and stderr, surfaces asynchronous spawn failures through startup, fails closed on unhandled permission requests, and owns graceful or signalled shutdown. Shutdown waits for process exit, inherited stdio closure, and ACP parser exhaustion before resolving or propagating a child error, so captures are complete and callers can remove owned paths after either outcome. When Windows accepts forced termination but publishes its exit marker asynchronously, shutdown gives that marker a bounded grace before treating fallback refusal as a second failure. Snapshot and ordinary e2e suites share this process boundary; a test supplies only agent paths, cwd, environment overrides, and any permission policy. - **`runScenario` (harness)** — drives ACP JSON-RPC stdio from a deterministic `input.json` script through the launcher, tees raw stdout for the expected-output and purity checks, and harvests every persisted raw JSONL session log (parent and subagent children, primary-first) after graceful stdin EOF. `AgentUnderTest` supplies absolute `binScript`, optional `libBinScript`, `configPath`, and `tsconfigPath` paths because the subprocess cwd is outside the repo; `workspaceParent` may move the generated child cwd from the platform temp directory when that grant is itself under test. Startup failures preserve captured agent stderr in the rejected diagnostic. -- **Normalizers** — pure functions turning captured surfaces into stable text or portable fixtures: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs and every native/JavaScript filesystem spelling of the generated cwd → tokens, longest-first; cwd-rooted separators selected as canonical `/` or host-native; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept, the same cwd-path policy), `tokenizeSessionFixtureCwd` (the generated workspace and its filesystem aliases → one canonical `{{cwd}}`, including an already-tokenized macOS `/private` alias; authored temp paths unchanged), `scrubSystemPrompts` (prompt text → `{{system}}`), `scrubToolSchemas` (schema bulk → `{{tools}}`), `scrubRequestHeaders` (all header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}` outside each pin, structure kept — [pinned-header Agent Note](../../../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)), and `stabilizeFixtureMessageIds` (committed UUIDs carried into unchanged, unambiguous messages across any recorder's fixture-ready parent/child logs). -- **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario expected-output and re-persisted-log comparisons, record/refresh fixture write-back, rejection of structured `UNKNOWN_TOOL` results, a tokenized pin per header class composed with independently shared `system-prompt.expected.md` and `tool-schemas.expected.json` sidecars, and a live uniformity guard. Its fixture guards reject orphan scenario dirs, missing files, multiple pins for one class, duplicate sidecar content, noncanonical macOS-prefixed cwd tokens, unscrubbed JSONL headers, and malformed pinning headers. Before record or refresh writes fixtures, an unchanged complete message retains its committed UUID when its identity-free value resolves to exactly one fresh ID and one existing ID across the scenario's parent/child logs; new, changed, and ambiguous messages keep fresh UUIDs. Refresh evaluates fresh leaves with the harvested run's ids, cwd, and every cwd alias, then reuses normalized-equivalent leaves only when the complete logical-record layout aligns and volatile string replacements form a bijection; ambiguous logs keep fresh strings, and fresh semantic values remain authoritative. It also expands packed timing envelopes before aligning event times, so switching between packed and unpacked layouts cannot shift later records. A newly inserted `session/title` receives its preceding event's time so feature-driven insertions do not churn the remainder of a fixture. Each scenario directory's `session.jsonl` plus contiguous `session..jsonl` siblings are the ordered primary/child inventory; the scenario table does not duplicate their count. Must be called at vitest collection time. +- **Normalizers** — pure functions turning captured surfaces into stable text or portable fixtures: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs and every native/JavaScript filesystem spelling of the generated cwd → tokens, longest-first; cwd-rooted separators selected as canonical `/` or host-native; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept, the same cwd-path policy), `tokenizeSessionFixtureCwd` (the generated workspace and its filesystem aliases → one canonical `{{cwd}}`, including an already-tokenized macOS `/private` alias; authored temp paths unchanged), `scrubSystemPrompts` (prompt text → `{{system}}`), `scrubToolSchemas` (schema bulk → `{{tools}}`), `scrubRequestHeaders` (all header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}` outside each pin, structure kept — [pinned-header Agent Note](../../../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)), and `stabilizeFixtureMessageIds` (committed UUIDs carried into unchanged, mutually unique messages by structurally rewriting only complete surface and durable-inbox message ID fields across any recorder's fixture-ready parent/child logs). +- **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario expected-output and re-persisted-log comparisons, record/refresh fixture write-back, rejection of structured `UNKNOWN_TOOL` results, a tokenized pin per header class composed with independently shared `system-prompt.expected.md` and `tool-schemas.expected.json` sidecars, and a live uniformity guard. Its fixture guards reject orphan scenario dirs, missing files, multiple pins for one class, duplicate sidecar content, noncanonical macOS-prefixed cwd tokens, unscrubbed JSONL headers, and malformed pinning headers. Before record or refresh writes fixtures, an unchanged complete message retains its committed UUID only when both its ID and identity-free fingerprint are unique across the scenario's fixture-ready parent/child logs; the session package's authoritative surface-type predicate selects surface carriers, correlated `agent/inbox/spliced` copies join the same mapping, and only validated `id` fields in those carriers are rewritten. New, changed, malformed, and graph-ambiguous messages keep fresh UUIDs. Refresh evaluates fresh leaves with the harvested run's ids, cwd, and every cwd alias, then reuses normalized-equivalent leaves only when the complete logical-record layout aligns and volatile string replacements form a bijection; complete message IDs in surface or inbox carriers are excluded because the later structural pass owns them, ambiguous logs keep fresh strings, and fresh semantic values remain authoritative. It also expands packed timing envelopes before aligning event times, so switching between packed and unpacked layouts cannot shift later records. A newly inserted `session/title` receives its preceding event's time so feature-driven insertions do not churn the remainder of a fixture. Each scenario directory's `session.jsonl` plus contiguous `session..jsonl` siblings are the ordered primary/child inventory; the scenario table does not duplicate their count. Must be called at vitest collection time. Committed session fixtures use canonical packed rows. An in-flight branch that merges this contract runs the [temporary repository migrator](../../../scripts/migrate-packed-session-fixtures.ts) with `pnpm run migrate:packed-session-fixtures`; its [removal proposal](../../../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md) owns deletion after affected branches converge. diff --git a/packages/support/acp-snapshot/README.zh.md b/packages/support/acp-snapshot/README.zh.md index 2e88e7c5bb..91be3c97bc 100644 --- a/packages/support/acp-snapshot/README.zh.md +++ b/packages/support/acp-snapshot/README.zh.md @@ -8,8 +8,8 @@ ACP 快照套件工具包:无密钥快照层(`pnpm run test:snapshot`,见[ - **`launchAcpTestAgent`(启动器)**:从指定 cwd 在 tsx 下启动源 agent,或在普通 Node 下启动已构建 `lib` agent;通过原始字节 stdout tee 连接 SDK 客户端,收集会话更新和 stderr,在启动过程中公开异步 spawn 失败,对未处理权限请求快速失败,并负责优雅或带信号关闭。关闭会等待进程退出、继承 stdio 关闭和 ACP parser 耗尽,然后才解析或传播子级错误,使捕获内容完整,且调用方可在任一结果后移除自有路径。当 Windows 接受强制终止但异步发布退出标记时,关闭会给该标记有界宽限,然后才将回退拒绝视为第二次失败。快照和普通 e2e 套件共享该进程边界;测试只需提供 agent 路径、cwd、环境覆盖和任何权限策略。 - **`runScenario`(harness)**:通过启动器从确定性 `input.json` 脚本驱动 ACP JSON-RPC stdio,将原始 stdout tee 给预期输出和纯度检查,并在优雅 stdin EOF 后收集每个持久化原始 JSONL 会话日志(父级和 subagent 子级,主级优先)。`AgentUnderTest` 提供绝对 `binScript`、可选 `libBinScript`、`configPath` 和 `tsconfigPath` 路径,因为子进程 cwd 位于仓库外。当生成子级 cwd 自身位于待测授权中时,`workspaceParent` 可以将它从平台临时目录移出。启动失败会在拒绝诊断中保留已捕获 agent stderr。 -- **规范化器**:将已捕获接口转换为稳定文本或可移植 fixture 的纯函数:`normalizeStdout`(JSON-RPC id → 首次出现序列;UUID 以及生成 cwd 的每个原生/JavaScript 文件系统写法 → token,按最长优先;根据 cwd 的分隔符选择规范 `/` 或宿主原生形式;同时作为 stdout 纯度检查)、`normalizeSessionLog`(时间归零、保留 `seq`、使用同一 cwd 路径策略)、`tokenizeSessionFixtureCwd`(生成的 workspace 及其文件系统别名,包括已 token 化的 macOS `/private` 别名 → 单一规范 `{{cwd}}`;手工编写的临时路径保持不变)、`scrubSystemPrompts`(提示词文本 → `{{system}}`)、`scrubToolSchemas`(schema bulk → `{{tools}}`)、`scrubRequestHeaders`(每个 pin 之外的所有 header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}`,保留结构;见[header 固定 Agent Note](../../../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md))和 `stabilizeFixtureMessageIds`(针对任意录制器已准备写入 fixture 的父级/子级日志,将已提交 UUID 带入未变化且无歧义的消息)。 -- **`defineAcpSnapshotSuite`(工厂)**:为场景表注册完整 describe/it 树:每场景预期输出与重新持久化日志比较、录制/刷新 fixture 回写、拒绝结构化 `UNKNOWN_TOOL` 结果、每个 header 类别一个 token 化 pin(由可独立共享的 `system-prompt.expected.md` 和 `tool-schemas.expected.json` sidecar 组合而成),以及实时一致性保护。其 fixture 保护会拒绝遗留场景目录、缺失文件、一个类别包含多个 pin、重复的 sidecar 内容、带非规范 macOS 前缀的 cwd token、未擦除的 JSONL header,以及格式错误的 pin header。在录制或刷新写入 fixture 前,如果一条未变化的完整消息去除身份后的值在场景的父级/子级日志中恰好对应一个本次生成的 ID 和一个现有 ID,它就会保留已提交的 UUID;新增、发生变化和有歧义的消息则保留本次生成的 UUID。刷新会使用收集所得本次运行的 id、cwd 及全部 cwd 别名评估本次生成的叶值;只有完整逻辑记录布局对齐且易变字符串替换形成双射时,才会复用规范化后等价的叶值;有歧义的日志保留本次生成的字符串,而本次生成的语义值仍为权威数据。它还会在对齐事件时间前展开打包时序 envelope,因此切换打包/非打包布局无法移动后续记录。新插入的 `session/title` 使用前一个事件的时间,因此功能驱动的插入不会扰动 fixture 余下部分。每个场景目录的 `session.jsonl` 和连续 `session..jsonl` 同级文件是有序主级/子级清单;场景表不重复其数量。必须在 vitest 收集时调用。 +- **规范化器**:将已捕获接口转换为稳定文本或可移植 fixture 的纯函数:`normalizeStdout`(JSON-RPC id → 首次出现序列;UUID 以及生成 cwd 的每个原生/JavaScript 文件系统写法 → token,按最长优先;根据 cwd 的分隔符选择规范 `/` 或宿主原生形式;同时作为 stdout 纯度检查)、`normalizeSessionLog`(时间归零、保留 `seq`、使用同一 cwd 路径策略)、`tokenizeSessionFixtureCwd`(生成的 workspace 及其文件系统别名,包括已 token 化的 macOS `/private` 别名 → 单一规范 `{{cwd}}`;手工编写的临时路径保持不变)、`scrubSystemPrompts`(提示词文本 → `{{system}}`)、`scrubToolSchemas`(schema bulk → `{{tools}}`)、`scrubRequestHeaders`(每个 pin 之外的所有 header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}`,保留结构;见[header 固定 Agent Note](../../../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md))和 `stabilizeFixtureMessageIds`(针对任意录制器已准备写入 fixture 的父级/子级日志,通过结构化方式仅改写 surface 和持久 inbox 中完整消息的 ID 字段,将已提交 UUID 带入未变化且双向唯一匹配的消息)。 +- **`defineAcpSnapshotSuite`(工厂)**:为场景表注册完整 describe/it 树:每场景预期输出与重新持久化日志比较、录制/刷新 fixture 回写、拒绝结构化 `UNKNOWN_TOOL` 结果、每个 header 类别一个 token 化 pin(由可独立共享的 `system-prompt.expected.md` 和 `tool-schemas.expected.json` sidecar 组合而成),以及实时一致性保护。其 fixture 保护会拒绝遗留场景目录、缺失文件、一个类别包含多个 pin、重复的 sidecar 内容、带非规范 macOS 前缀的 cwd token、未擦除的 JSONL header,以及格式错误的 pin header。在录制或刷新写入 fixture 前,仅当一条未变化完整消息的 ID 及其去除身份后的指纹在场景可写入 fixture 的父级/子级日志中均唯一时,该消息才会保留已提交的 UUID;会话包的权威 surface 类型谓词负责选择 surface 载体,与其关联的 `agent/inbox/spliced` 副本也纳入同一映射,且仅改写这些载体中通过验证的 `id` 字段。新增、发生变化、格式错误以及图关系存在歧义的消息保留本次生成的 UUID。刷新会使用收集所得本次运行的 id、cwd 及全部 cwd 别名评估本次生成的叶值;只有完整逻辑记录布局对齐且易变字符串替换形成双射时,才会复用归一化后等价的叶值;surface 或 inbox 载体中的完整消息 ID 不参与此路径,因为后续结构化处理负责这些 ID;有歧义的日志保留本次生成的字符串,而本次生成的语义值仍为权威数据。它还会在对齐事件时间前展开打包时序 envelope,因此切换打包/非打包布局无法移动后续记录。新插入的 `session/title` 使用前一个事件的时间,因此功能驱动的插入不会扰动 fixture 余下部分。每个场景目录的 `session.jsonl` 和连续 `session..jsonl` 同级文件是有序主级/子级清单;场景表不重复其数量。必须在 vitest 收集时调用。 签入仓库的会话 fixture 使用规范打包行。合并此契约的在途分支通过 `pnpm run migrate:packed-session-fixtures` 运行[临时仓库迁移器](../../../scripts/migrate-packed-session-fixtures.ts);待受影响分支收敛后,由其[移除提案](../../../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md)负责删除该迁移器。 diff --git a/packages/support/acp-snapshot/package.json b/packages/support/acp-snapshot/package.json index c231591103..b504cbe50d 100644 --- a/packages/support/acp-snapshot/package.json +++ b/packages/support/acp-snapshot/package.json @@ -31,10 +31,12 @@ }, "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/support/acp-snapshot/src/suite.ts b/packages/support/acp-snapshot/src/suite.ts index 993aee254f..c0cfc56e24 100644 --- a/packages/support/acp-snapshot/src/suite.ts +++ b/packages/support/acp-snapshot/src/suite.ts @@ -20,6 +20,7 @@ import { readFile, readdir, rm, writeFile } from 'node:fs/promises' import { existsSync } from 'node:fs' import { join } from 'node:path' +import { isSurfaceEligibleType } from '@deepseek-ai/dsh-session/surface' import { describe, expect, it } from 'vitest' import { type AgentUnderTest, type HarvestedLog, type InputScript, runScenario } from './harness.ts' import { @@ -512,11 +513,11 @@ export function headerChangeCount(rawLog: string): number { .length } -/** A literal string replacement used to carry an existing fixture value into fresh write-back. */ +/** A literal replacement from a fresh replay-run volatile to its existing fixture value. */ export interface FixtureReplacement { - /** The fresh run's value to replace. */ + /** The fresh replay run's volatile value. */ from: string - /** The existing fixture value to keep. */ + /** The existing fixture value retained during write-back. */ to: string } @@ -526,24 +527,51 @@ function parseJsonlRecords(text: string): Record[] { .map(line => JSON.parse(line) as Record) } +/** Narrow one parsed value to the complete identified-message shape retained by fixtures. */ +function completeMessage(value: unknown): Record | undefined { + if ( + !isRecord(value) + || typeof value.id !== 'string' + || !UUID_RE.test(value.id) + || typeof value.role !== 'string' + || !Array.isArray(value.content) + || !isRecord(value.source) + ) return undefined + return value +} + /** Return the complete identified message carried by one surface event. */ -function eventMessage(record: Record): Record | undefined { +function surfaceEventMessage(record: Record): Record | undefined { + const type = record.type + if (typeof type !== 'string' || !isSurfaceEligibleType(type)) return undefined const data = record.data if (!isRecord(data)) return undefined - const message = record.type === 'user/message' - ? data - : record.type === 'assistant/message' || record.type === 'tool/result' || record.type === 'steering/message' - ? data.message - : undefined - if ( - !isRecord(message) - || typeof message.id !== 'string' - || !UUID_RE.test(message.id) - || typeof message.role !== 'string' - || !Array.isArray(message.content) - || !isRecord(message.source) - ) return undefined - return message + let message: unknown + switch (type) { + case 'user/message': + message = data + break + case 'assistant/message': + case 'tool/result': + message = data.message + break + /* v8 ignore next -- the authoritative predicate must fail loud when a new surface shape lands. */ + default: throw new Error(`acp-snapshot: unsupported surface event type "${type}"`) + } + return completeMessage(message) +} + +/** Return complete message identities structurally owned by one durable record. */ +function recordMessages(record: Record): Record[] { + const surfaceMessage = surfaceEventMessage(record) + if (surfaceMessage !== undefined) return [surfaceMessage] + if (record.type !== 'agent/inbox/spliced' || !isRecord(record.data) || !Array.isArray(record.data.inserted)) { + return [] + } + return record.data.inserted.flatMap((value) => { + const message = completeMessage(value) + return message === undefined ? [] : [message] + }) } /** Serialize parsed JSON by value rather than insertion order. */ @@ -555,42 +583,48 @@ function canonicalJson(value: unknown): string { return JSON.stringify(value) } -/** Index each unambiguous identity-free message value by its sole message id. */ -function uniqueMessageIds(logs: readonly string[]): Map { - const fingerprintsById = new Map() +/** Index identity-free message values whose ID and fingerprint are mutually unique. */ +function uniqueMessageIds(logs: readonly string[]): Map { + const fingerprintsById = new Map>() + const idsByFingerprint = new Map>() for (const log of logs) { for (const record of parseJsonlRecords(log)) { - const message = eventMessage(record) - if (message === undefined) continue - const { id, ...withoutId } = message - const messageId = id as string - const fingerprint = canonicalJson(withoutId) - if (!fingerprintsById.has(messageId)) fingerprintsById.set(messageId, fingerprint) - else if (fingerprintsById.get(messageId) !== fingerprint) fingerprintsById.set(messageId, undefined) + for (const message of recordMessages(record)) { + const { id, ...withoutId } = message + const messageId = id as string + const fingerprint = canonicalJson(withoutId) + const fingerprints = fingerprintsById.get(messageId) + if (fingerprints === undefined) fingerprintsById.set(messageId, new Set([fingerprint])) + else fingerprints.add(fingerprint) + const ids = idsByFingerprint.get(fingerprint) + if (ids === undefined) idsByFingerprint.set(fingerprint, new Set([messageId])) + else ids.add(messageId) + } } } - const idsByFingerprint = new Map() - for (const [id, fingerprint] of fingerprintsById) { - if (fingerprint === undefined) continue - if (!idsByFingerprint.has(fingerprint)) idsByFingerprint.set(fingerprint, id) - else idsByFingerprint.set(fingerprint, undefined) + const unique = new Map() + for (const [id, fingerprints] of fingerprintsById) { + if (fingerprints.size !== 1) continue + const fingerprint = fingerprints.values().next().value as string + if (idsByFingerprint.get(fingerprint)?.size !== 1) continue + unique.set(fingerprint, id) } - return idsByFingerprint + return unique } /** * Match unchanged complete messages across a scenario's fresh and existing logs. - * New, changed, repeated, or otherwise ambiguous messages keep their fresh ids. + * New, changed, duplicate-content, or otherwise ambiguous messages keep their fresh ids. */ -function fixtureMessageIdReplacements(logs: readonly string[], fixtures: readonly string[]): FixtureReplacement[] { +function fixtureMessageIdReplacements(logs: readonly string[], fixtures: readonly string[]): Map { const freshIds = uniqueMessageIds(logs) const existingIds = uniqueMessageIds(fixtures) - const replacements: FixtureReplacement[] = [] + const replacements = new Map() for (const [fingerprint, fresh] of freshIds) { const existing = existingIds.get(fingerprint) - if (fresh === undefined || existing === undefined || fresh === existing) continue - replacements.push({ from: fresh, to: existing }) + if (existing === undefined || fresh === existing) continue + replacements.set(fresh, existing) } return replacements } @@ -602,6 +636,22 @@ function applyFixtureReplacements(content: string, replacements: readonly Fixtur return stable } +/** Rewrite only validated durable-message ID fields, leaving every other occurrence untouched. */ +function applyFixtureMessageIds(content: string, replacements: ReadonlyMap): string { + return content.split('\n').map((line) => { + if (line.trim().length === 0) return line + const record = JSON.parse(line) as Record + let changed = false + for (const message of recordMessages(record)) { + const replacement = replacements.get(message.id as string) + if (replacement === undefined) continue + message.id = replacement + changed = true + } + return changed ? JSON.stringify(record) : line + }).join('\n') +} + /** * Carry committed UUIDs into unchanged, unambiguous messages in fresh session fixtures. * @@ -611,7 +661,7 @@ function applyFixtureReplacements(content: string, replacements: readonly Fixtur */ export function stabilizeFixtureMessageIds(logs: readonly string[], fixtures: readonly string[]): string[] { const replacements = fixtureMessageIdReplacements(logs, fixtures) - return logs.map(log => applyFixtureReplacements(log, replacements)) + return logs.map(log => applyFixtureMessageIds(log, replacements)) } /** One packed row's member times, or `undefined` for an ordinary record. */ @@ -659,15 +709,15 @@ export function unknownToolCallIds(rawLog: string): string[] { } /** - * Build refresh write-back replacements: scenario-wide unchanged message ids, - * plus per-log session ids, cwd values, and spill paths. + * Build refresh write-back replacements for per-log session ids, cwd values, + * and spill paths. Durable message ids have a later structural owner. * * @param logs The freshly harvested logs, in fixture order. * @param fixtures The existing fixture contents, in matching order. * @returns Literal replacements from fresh values to the fixture's existing values. */ export function refreshFixtureReplacements(logs: HarvestedLog[], fixtures: string[]): FixtureReplacement[] { - const replacements = fixtureMessageIdReplacements(logs.map(log => log.content), fixtures) + const replacements: FixtureReplacement[] = [] for (let i = 0; i < logs.length; i++) { const fresh = parseJsonlRecords((logs[i] as HarvestedLog).content)[0] const existing = parseJsonlRecords(fixtures[i] ?? '')[0] @@ -818,6 +868,7 @@ function collectNormalizedStringMappings( existing: unknown, normalizedFresh: unknown, normalizedExisting: unknown, + excludedStrings: ReadonlySet, forward: Map, reverse: Map, ): boolean { @@ -837,6 +888,7 @@ function collectNormalizedStringMappings( existing[index], normalizedFresh[index], normalizedExisting[index], + excludedStrings, forward, reverse, )) @@ -856,6 +908,7 @@ function collectNormalizedStringMappings( existing[key], normalizedFresh[key], normalizedExisting[key], + excludedStrings, forward, reverse, )) @@ -866,6 +919,8 @@ function collectNormalizedStringMappings( || typeof normalizedFresh !== 'string' || normalizedFresh !== normalizedExisting || fresh === existing + || excludedStrings.has(fresh) + || excludedStrings.has(existing) ) return true const freshKey = JSON.stringify([normalizedFresh, fresh]) const existingKey = JSON.stringify([normalizedFresh, existing]) @@ -891,6 +946,10 @@ function normalizedStringMappings( freshContext: NormalizeContext, existingContext: NormalizeContext, ): Map | undefined { + const excludedStrings = new Set() + for (const record of [...freshRecords, ...existingRecords]) { + for (const message of recordMessages(record)) excludedStrings.add(message.id as string) + } const forward = new Map() const reverse = new Map() let existingIndex = 0 @@ -912,6 +971,7 @@ function normalizedStringMappings( existingRecord, normalizedRefreshRecord(freshRecords[recordIndex] as Record, freshContext), normalizedRefreshRecord(existingRecord, existingContext), + excludedStrings, forward, reverse, )) return undefined @@ -924,11 +984,13 @@ function normalizedStringMappings( /** * Rewrite a fresh replay-produced log so repeated refreshes do not churn * volatile fixture fields. Meaningful event payloads come from `fresh`; the - * existing fixture lends normalized-equivalent values, including ids, paths, + * existing fixture lends normalized-equivalent values, including non-message ids, paths, * creation/event times, spill locators, and hook durations, only when the * complete record layout aligns and volatile strings form a consistent - * bijection. Ambiguous layouts or mappings keep fresh strings. Packed timing - * envelopes expand for alignment, so packing does not shift later records; + * bijection. Complete durable-message ids are excluded because the later + * fixture-ready structural pass owns them. Ambiguous layouts or mappings + * keep fresh strings. Packed timing envelopes expand for alignment, so + * packing does not shift later records; * fresh semantic values and fragment arrays remain authoritative. * * @param fresh The newly harvested session JSONL. @@ -1158,17 +1220,15 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { const refreshReplacements = REFRESHING ? refreshFixtureReplacements(result.sessionLogs, existingFixtures) : [] - const outputFixtures = REFRESHING + const freshFixtures = REFRESHING ? result.sessionLogs.map((log, index) => scrub(portableFixture(stabilizeRefreshLog( log.content, existingFixtures[index] as string, refreshReplacements, ctx, )))) - : stabilizeFixtureMessageIds( - result.sessionLogs.map(log => scrub(portableFixture(log.content))), - existingFixtures, - ) + : result.sessionLogs.map(log => scrub(portableFixture(log.content))) + const outputFixtures = stabilizeFixtureMessageIds(freshFixtures, existingFixtures) await Promise.all(outputFixtures.map((fixture, index) => writeFile(join(dir, outputFixtureFiles[index] as string), fixture))) if (RECORDING) { diff --git a/packages/support/acp-snapshot/tests/suite.spec.ts b/packages/support/acp-snapshot/tests/suite.spec.ts index a915590b5d..3cfd1c0f0a 100644 --- a/packages/support/acp-snapshot/tests/suite.spec.ts +++ b/packages/support/acp-snapshot/tests/suite.spec.ts @@ -7,6 +7,7 @@ import { afterAll, describe, expect, it } from 'vitest' import { defineAcpSnapshotSuite, stabilizeFixtureMessageIds, + tokenizeSessionFixtureCwd, type HarvestedLog, type Scenario, } from '../src/index.ts' @@ -679,6 +680,109 @@ describe('stabilizeFixtureMessageIds', () => { } }) + it('rewrites only complete messages carried by surface events or durable inbox splices', () => { + const ids = { + freshUser: '11111111-1111-4111-8111-111111111111', + oldUser: '22222222-2222-4222-8222-222222222222', + freshAssistant: '33333333-3333-4333-8333-333333333333', + oldAssistant: '44444444-4444-4444-8444-444444444444', + freshTool: '55555555-5555-4555-8555-555555555555', + oldTool: '66666666-6666-4666-8666-666666666666', + oldMalformed: '77777777-7777-4777-8777-777777777777', + } as const + const message = (id: string, role: string, text: string): Record => ({ + id, + role, + content: [{ type: 'text', text }], + source: { kind: role === 'user' ? 'user' : 'model' }, + }) + const log = (userId: string, assistantId: string, toolId: string, malformedId: string): string => [ + JSON.stringify({ type: 'session', id: 'same', cwd: '{{cwd}}' }), + JSON.stringify({ + type: 'agent/inbox/spliced', + data: { + inserted: [ + message(userId, 'user', 'user'), + { ...message(userId, 'user', 'malformed inbox'), source: null }, + ], + }, + }), + JSON.stringify({ type: 'user/message', data: message(userId, 'user', 'user') }), + JSON.stringify({ type: 'assistant/message', data: { message: message(assistantId, 'assistant', 'assistant') } }), + JSON.stringify({ type: 'tool/result', data: { message: message(toolId, 'tool', 'tool') } }), + JSON.stringify({ type: 'turn/start', data: { id: userId } }), + JSON.stringify({ type: 'steering/message', data: message(userId, 'user', 'obsolete') }), + JSON.stringify({ type: 'user/message', data: { ...message(userId, 'user', 'malformed'), source: null } }), + JSON.stringify({ type: 'user/message', data: message(malformedId, 'user', 'non-UUID') }), + JSON.stringify({ type: 'assistant/message', data: null }), + JSON.stringify({ type: 42, data: message(userId, 'user', 'non-string type') }), + '', + ].join('\n') + + const stable = stabilizeFixtureMessageIds( + [log(ids.freshUser, ids.freshAssistant, ids.freshTool, 'not-a-uuid')], + [log(ids.oldUser, ids.oldAssistant, ids.oldTool, ids.oldMalformed)], + )[0] as string + const records = stable.trim().split('\n').map(line => JSON.parse(line) as Record) + + const inserted = ((records[1]?.data as { inserted: Array<{ id: string }> }).inserted) + expect(inserted[0]?.id).toBe(ids.oldUser) + expect(inserted[1]?.id).toBe(ids.freshUser) + expect((records[2]?.data as { id: string }).id).toBe(ids.oldUser) + expect((records[3]?.data as { message: { id: string } }).message.id).toBe(ids.oldAssistant) + expect((records[4]?.data as { message: { id: string } }).message.id).toBe(ids.oldTool) + expect((records[5]?.data as { id: string }).id).toBe(ids.freshUser) + expect((records[6]?.data as { id: string }).id).toBe(ids.freshUser) + expect((records[7]?.data as { id: string }).id).toBe(ids.freshUser) + expect((records[8]?.data as { id: string }).id).toBe('not-a-uuid') + }) + + it('matches cwd-bearing messages only after the fresh log reaches fixture-ready form', () => { + const freshId = '11111111-1111-4111-8111-111111111111' + const existingId = '22222222-2222-4222-8222-222222222222' + const freshCwd = '/tmp/acp-snapshot-fresh-cwd' + const message = (id: string, path: string): Record => ({ + type: 'user/message', + data: { + id, + role: 'user', + content: [{ type: 'text', text: `read ${path}/input.txt` }], + source: { kind: 'user' }, + }, + }) + const fresh = tokenizeSessionFixtureCwd([ + JSON.stringify({ type: 'session', id: 'fresh', cwd: freshCwd }), + JSON.stringify(message(freshId, freshCwd)), + '', + ].join('\n')) + const existing = [ + JSON.stringify({ type: 'session', id: 'old', cwd: '{{cwd}}' }), + JSON.stringify(message(existingId, '{{cwd}}')), + '', + ].join('\n') + + expect(stabilizeFixtureMessageIds([fresh], [existing])[0]).toContain(`"id":"${existingId}"`) + }) + + it('rejects a fingerprint connected to an id that also identifies different content', () => { + const freshId = '11111111-1111-4111-8111-111111111111' + const conflictingId = '22222222-2222-4222-8222-222222222222' + const competingId = '33333333-3333-4333-8333-333333333333' + const message = (id: string, text: string): string => JSON.stringify({ + type: 'user/message', + data: { id, role: 'user', content: [{ type: 'text', text }], source: { kind: 'user' } }, + }) + const fresh = `${message(freshId, 'shared')}\n` + const existing = [ + message(conflictingId, 'shared'), + message(conflictingId, 'different'), + message(competingId, 'shared'), + '', + ].join('\n') + + expect(stabilizeFixtureMessageIds([fresh], [existing])).toEqual([fresh]) + }) + it('leaves fresh fixtures unchanged when no committed counterpart exists', () => { const fresh = '{"type":"session","id":"new"}\n' expect(stabilizeFixtureMessageIds([fresh], [''])).toEqual([fresh]) @@ -726,76 +830,28 @@ describe('refreshFixtureReplacements', () => { ]) }) - it('maps one inherited message id across parent and child logs', () => { + it('leaves complete message ids out of the literal refresh replacement list', () => { const freshMessageId = '11111111-1111-4111-8111-111111111111' const existingMessageId = '22222222-2222-4222-8222-222222222222' - const content = [{ type: 'text', text: 'inherited' }] const log = (sessionId: string, messageId: string): string => [ JSON.stringify({ type: 'session', id: sessionId, cwd: '/same' }), JSON.stringify({ type: 'user/message', - data: { role: 'user', content, source: { kind: 'user' }, id: messageId }, + data: { + id: messageId, + role: 'user', + content: [{ type: 'text', text: 'same' }], + source: { kind: 'user' }, + }, }), '', ].join('\n') - const harvested = (content: string): HarvestedLog => ({ id: 'diagnostic', createdAt: 1, content }) - const replacements = refreshFixtureReplacements( - [harvested(log('fresh-parent', freshMessageId)), harvested(log('fresh-child', freshMessageId))], - [log('old-parent', existingMessageId), log('old-child', existingMessageId)], + [{ id: 'diagnostic', createdAt: 1, content: log('fresh', freshMessageId) }], + [log('old', existingMessageId)], ) - expect(replacements.filter(replacement => replacement.from === freshMessageId)).toEqual([ - { from: freshMessageId, to: existingMessageId }, - ]) - }) - - it('keeps fresh ids for new, changed, and ambiguous messages', () => { - const ids = { - new: '11111111-1111-4111-8111-111111111111', - changed: '22222222-2222-4222-8222-222222222222', - ambiguousA: '33333333-3333-4333-8333-333333333333', - ambiguousB: '44444444-4444-4444-8444-444444444444', - oldChanged: '55555555-5555-4555-8555-555555555555', - oldAmbiguous: '66666666-6666-4666-8666-666666666666', - stable: '77777777-7777-4777-8777-777777777777', - } as const - const message = (id: string, text: string): Record => ({ - type: 'user/message', - data: { role: 'user', content: [{ type: 'text', text }], source: { kind: 'user' }, id }, - }) - const log = (messages: Record[]): string => [ - JSON.stringify({ type: 'session', id: 'same', cwd: '/same' }), - ...messages.map(record => JSON.stringify(record)), - '', - ].join('\n') - const fresh = log([ - message(ids.new, 'new'), - message(ids.changed, 'changed'), - message(ids.changed, 'changed again'), - message(ids.ambiguousA, 'duplicate'), - message(ids.ambiguousB, 'duplicate'), - message(ids.stable, 'stable'), - ]) - const existing = log([ - message(ids.oldChanged, 'before'), - message(ids.oldAmbiguous, 'duplicate'), - message(ids.stable, 'stable'), - ]) - - const replacements = refreshFixtureReplacements( - [{ id: 'diagnostic', createdAt: 1, content: fresh }], - [existing], - ) - - const replacedIds = replacements.map(replacement => replacement.from) - for (const id of [ - ids.new, - ids.changed, - ids.ambiguousA, - ids.ambiguousB, - ids.stable, - ]) expect(replacedIds).not.toContain(id) + expect(replacements).toEqual([{ from: 'fresh', to: 'old' }]) }) }) @@ -936,13 +992,38 @@ describe('stabilizeRefreshLog', () => { [{ id: 'diagnostic', createdAt: 1, content: fresh }], [existing], ) - const output = stabilize(fresh, existing, replacements).trim().split('\n') + const refreshed = stabilize(fresh, existing, replacements) + const intermediate = refreshed.trim().split('\n') + .map(line => JSON.parse(line) as Record) + expect((intermediate[1]?.data as { id: string }).id).toBe(freshUserId) + expect(((intermediate[3]?.data as { message: { id: string } }).message).id).toBe(freshAssistantId) + + const output = (stabilizeFixtureMessageIds([refreshed], [existing])[0] as string).trim().split('\n') .map(line => JSON.parse(line) as Record) expect((output[1]?.data as { id: string }).id).toBe(existingUserId) expect(((output[3]?.data as { message: { id: string } }).message).id).toBe(existingAssistantId) }) + it('leaves an aligned complete message id to the fixture-ready structural pass', () => { + const freshId = '11111111-1111-4111-8111-111111111111' + const existingId = '22222222-2222-4222-8222-222222222222' + const log = (id: string): string => [ + JSON.stringify({ type: 'session', id: 'same', createdAt: 1, cwd: '/same' }), + JSON.stringify({ + type: 'user/message', + data: { id, role: 'user', content: [{ type: 'text', text: 'same' }], source: { kind: 'user' } }, + }), + '', + ].join('\n') + const fresh = log(freshId) + const existing = log(existingId) + const refreshed = stabilize(fresh, existing) + + expect(refreshed).toContain(`"id":"${freshId}"`) + expect(stabilizeFixtureMessageIds([refreshed], [existing])[0]).toContain(`"id":"${existingId}"`) + }) + it('keeps volatile fixture fields while preserving fresh meaningful payloads', () => { const fresh = [ '{"type":"session","id":"new-child","createdAt":200,"cwd":"/new","parentSession":"new-parent","seedLength":1}', diff --git a/packages/support/acp-snapshot/tsconfig.json b/packages/support/acp-snapshot/tsconfig.json index 893282ce51..9d85ccaf1f 100644 --- a/packages/support/acp-snapshot/tsconfig.json +++ b/packages/support/acp-snapshot/tsconfig.json @@ -13,6 +13,9 @@ }, { "path": "../../support/invariants" + }, + { + "path": "../../core/session" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 078f775ecf..757da9b662 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5907,6 +5907,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session cordis: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis From de93253b148c737355f0d700843211b5916ec3fd Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 02:18:40 +0800 Subject: [PATCH 31/37] docs: refresh module graph --- docs/module-graph.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index d963273363..bd9bcbf070 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -300,7 +300,6 @@ flowchart TD pkg_timeout --> pkg_invariants pkg_scope --> pkg_invariants pkg_skill --> pkg_invariants - pkg_acp_snapshot --> pkg_invariants pkg_llm_mock_server --> pkg_invariants pkg_loader_smoke --> pkg_invariants pkg_base --> pkg_invariants @@ -422,6 +421,8 @@ flowchart TD pkg_session_persistence --> pkg_brand pkg_session_persistence --> pkg_invariants pkg_session_persistence --> pkg_session + pkg_acp_snapshot --> pkg_invariants + pkg_acp_snapshot --> pkg_session pkg_app_boot --> pkg_environment pkg_app_boot --> pkg_invariants pkg_app_boot --> pkg_paths @@ -1168,7 +1169,6 @@ flowchart TD | [`timeout`](../packages/util/timeout) | `util` | [`invariants`](../packages/support/invariants) | | [`scope`](../packages/core/scope) | `core` | [`invariants`](../packages/support/invariants) | | [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/support/invariants) | -| [`acp-snapshot`](../packages/support/acp-snapshot) | `support` | [`invariants`](../packages/support/invariants) | | [`llm-mock-server`](../packages/support/llm-mock-server) | `support` | [`invariants`](../packages/support/invariants) | | [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`invariants`](../packages/support/invariants) | | [`base`](../packages/bundle/base) | `bundle` | [`invariants`](../packages/support/invariants) | @@ -1220,6 +1220,7 @@ 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) | +| [`acp-snapshot`](../packages/support/acp-snapshot) | `support` | [`invariants`](../packages/support/invariants), [`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) | From 9c3d5725a5735f72d1e6dbbb3d22c7de5e17d758 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 8 Aug 2026 03:29:12 +0800 Subject: [PATCH 32/37] build: order Host and Client compilation faces --- package.json | 7 +- packages/api/remotes/tsconfig.client.json | 22 ++++ packages/api/remotes/tsconfig.host.json | 36 ++++++ packages/api/remotes/tsconfig.json | 37 +----- packages/api/remotes/tsdown.config.ts | 6 +- packages/client/runtime/package.json | 3 - packages/client/runtime/src/client/index.ts | 5 +- packages/client/runtime/tsconfig.json | 3 - packages/client/schema-form/tsdown.config.ts | 6 + packages/client/test-runtime/tsdown.config.ts | 6 + packages/client/tsdown.client.ts | 109 ++++++++++++++++-- packages/client/ui-goal/tsconfig.json | 2 +- .../client/ui-primitives/tsdown.config.ts | 6 +- packages/client/ui-slots/tsdown.config.ts | 6 + packages/client/ui-theme/tsdown.config.ts | 12 +- packages/client/web-react/tsdown.config.ts | 4 +- packages/client/web/tsdown.config.ts | 6 +- packages/host/apiproxy/tsconfig.json | 2 +- .../directory-picker-native/tsdown.config.ts | 29 ++--- packages/typert/generator/src/analyzer.ts | 17 ++- .../typert/generator/tests/type-model.spec.ts | 82 +++++++++++++ pnpm-lock.yaml | 3 - scripts/client-bundle-css.spec.ts | 9 +- scripts/client-bundle-purity.spec.ts | 42 +++++-- scripts/doc-typecheck.ts | 29 ++--- scripts/package-invariants.spec.ts | 14 +++ scripts/package-invariants.ts | 30 ++++- scripts/wine-windows-gates.sh | 16 ++- tsconfig.client.json | 2 +- tsconfig.host.json | 1 + tsdown.config.ts | 52 ++++----- tsdown.typert-host.config.ts | 20 ---- 32 files changed, 440 insertions(+), 184 deletions(-) create mode 100644 packages/api/remotes/tsconfig.client.json create mode 100644 packages/api/remotes/tsconfig.host.json create mode 100644 packages/client/schema-form/tsdown.config.ts create mode 100644 packages/client/test-runtime/tsdown.config.ts create mode 100644 packages/client/ui-slots/tsdown.config.ts delete mode 100644 tsdown.typert-host.config.ts diff --git a/package.json b/package.json index b1ad6853fc..def6e5bdcb 100644 --- a/package.json +++ b/package.json @@ -16,13 +16,12 @@ "scripts": { "build": "npm run build:lib && npm run build:web", "build:lib": "npm run build:lib:host && npm run build:lib:client", - "build:lib:host": "npm run build:lib:contracts && tsc -b tsconfig.host.json", - "build:lib:contracts": "tsc -b packages/typert/generator && tsdown --config tsdown.typert-host.config.ts", - "build:lib:client": "tsc -b tsconfig.client.json && tsdown", + "build:lib:host": "tsc -b tsconfig.host.json && tsdown --env.DSH_BUILD_FACE host", + "build:lib:client": "tsc -b tsconfig.client.json && tsdown --env.DSH_BUILD_FACE client", "build:web": "pnpm --filter @deepseek-ai/dsh-frontend run build", "clean": "tsx scripts/clean.ts", "change-scope": "tsx scripts/change-scope.ts", - "typecheck": "npm run build:lib:contracts && tsc -b", + "typecheck": "npm run build:lib:host && tsc -b tsconfig.client.json", "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", diff --git a/packages/api/remotes/tsconfig.client.json b/packages/api/remotes/tsconfig.client.json new file mode 100644 index 0000000000..bc26c0b13f --- /dev/null +++ b/packages/api/remotes/tsconfig.client.json @@ -0,0 +1,22 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo" + }, + "files": [ + "src/client/index.ts" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../goal/goal" + }, + { + "path": "../../typert/type-meta" + } + ] +} diff --git a/packages/api/remotes/tsconfig.host.json b/packages/api/remotes/tsconfig.host.json new file mode 100644 index 0000000000..1d4c35a9e2 --- /dev/null +++ b/packages/api/remotes/tsconfig.host.json @@ -0,0 +1,36 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo" + }, + "files": [ + "src/agent-lookup.ts", + "src/index.ts", + "src/invariant.ts" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../core/session" + }, + { + "path": "../../session-persistence/session-persistence" + }, + { + "path": "../../support/invariants" + }, + { + "path": "../../typert/registry" + }, + { + "path": "../../typert/type-meta" + } + ] +} diff --git a/packages/api/remotes/tsconfig.json b/packages/api/remotes/tsconfig.json index 148804dc0f..2eca820546 100644 --- a/packages/api/remotes/tsconfig.json +++ b/packages/api/remotes/tsconfig.json @@ -1,42 +1,11 @@ { - "extends": "../../../tsconfig.base.client.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types" - }, - "include": [ - "src" - ], + "files": [], "references": [ { - "path": "../../../vendor/cordis" + "path": "./tsconfig.host.json" }, { - "path": "../../core/agent" - }, - { - "path": "../../core/session" - }, - { - "path": "../../session-persistence/session-persistence" - }, - { - "path": "../../typert/type-meta" - }, - { - "path": "../../typert/registry" - }, - { - "path": "../../ui/commands" - }, - { - "path": "../../goal/goal" - }, - { - "path": "../../session-title/session-title" - }, - { - "path": "../../support/invariants" + "path": "./tsconfig.client.json" } ] } diff --git a/packages/api/remotes/tsdown.config.ts b/packages/api/remotes/tsdown.config.ts index 287b2c7975..3c72df8718 100644 --- a/packages/api/remotes/tsdown.config.ts +++ b/packages/api/remotes/tsdown.config.ts @@ -1,3 +1,7 @@ import { clientBundle } from '../../client/tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-api-remotes', ['lib/types/index.js', 'lib/types/invariant.js']) +export default clientBundle( + '@deepseek-ai/dsh-api-remotes', + ['lib/types/index.js', 'lib/types/invariant.js'], + { hostPhase: true }, +) diff --git a/packages/client/runtime/package.json b/packages/client/runtime/package.json index 711510b705..3d97e70de5 100644 --- a/packages/client/runtime/package.json +++ b/packages/client/runtime/package.json @@ -25,7 +25,6 @@ "dshClient": { "inject": [ "@deepseek-ai/dsh-client-connection", - "@deepseek-ai/dsh-api-remotes", "@deepseek-ai/dsh-typert-registry" ], "platform": "web", @@ -49,14 +48,12 @@ }, "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-api-remotes": "^0.0.1", "@deepseek-ai/dsh-type-meta": "^0.0.1", "@deepseek-ai/dsh-typert-registry": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-type-meta": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^", diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 5a1677df96..e4f8e57b04 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -1,7 +1,6 @@ /** Browser runtime services for slots, sessions, workspaces, and connection-stream delivery. */ import type { Context } from 'cordis' import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client' -import type {} from '@deepseek-ai/dsh-api-remotes/client' import type { TypeRTContext } from '@deepseek-ai/dsh-type-meta' import type { MaybeSnapshotSelectorHook, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' import { SlotsService } from './slots.ts' @@ -179,8 +178,8 @@ declare module 'cordis' { } } -/** Required services: the Remote root, wire handle, and Client TypeRT registry. */ -export const inject = ['remote', 'connection', 'typert'] +/** Required services: the wire handle and Client TypeRT registry. */ +export const inject = ['connection', 'typert'] /** Mounts the browser runtime services and connection stream. * @param ctx - Client Cordis context. diff --git a/packages/client/runtime/tsconfig.json b/packages/client/runtime/tsconfig.json index efbf7c26d7..f93546e855 100644 --- a/packages/client/runtime/tsconfig.json +++ b/packages/client/runtime/tsconfig.json @@ -20,9 +20,6 @@ { "path": "../connection" }, - { - "path": "../../api/remotes" - }, { "path": "../../host/apiproxy" }, diff --git a/packages/client/schema-form/tsdown.config.ts b/packages/client/schema-form/tsdown.config.ts new file mode 100644 index 0000000000..b03542c74e --- /dev/null +++ b/packages/client/schema-form/tsdown.config.ts @@ -0,0 +1,6 @@ +import { clientLibrary } from '../tsdown.client.ts' + +export default clientLibrary( + '@deepseek-ai/dsh-client-schema-form', + ['lib/types/index.js', 'lib/types/invariant.js'], +) diff --git a/packages/client/test-runtime/tsdown.config.ts b/packages/client/test-runtime/tsdown.config.ts new file mode 100644 index 0000000000..e2cb484ffb --- /dev/null +++ b/packages/client/test-runtime/tsdown.config.ts @@ -0,0 +1,6 @@ +import { clientLibrary } from '../tsdown.client.ts' + +export default clientLibrary( + '@deepseek-ai/dsh-client-test-runtime', + ['lib/types/index.js', 'lib/types/invariant.js'], +) diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index 74facbd69b..f2b7b7a3e6 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -9,6 +9,7 @@ * The virtual loader registers each real stylesheet as a watch dependency. */ import { readFile } from 'node:fs/promises' +import { existsSync } from 'node:fs' import { basename, dirname, relative, resolve as resolvePath, sep } from 'node:path' import { fileURLToPath } from 'node:url' import type { UserConfig } from 'tsdown' @@ -34,6 +35,12 @@ export const INLINE_SAFE = /^@deepseek-ai\/dsh-(host-apiproxy|session|llm|tools| /** Generated descriptor/codec contribution with no shared runtime identity. */ const GENERATED_REMOTE = /^@deepseek-ai\/dsh-[a-z0-9]+(?:-[a-z0-9]+)*\/remote$/ +/** + * Workspace mode replaces an empty config array with the root defaults. A + * falsey entry instead removes this package before entry resolution. + */ +const SKIP_WORKSPACE_BUILD: UserConfig = { entry: '' } + /** * Documented TEMPORARY exemption, not a platform module (hence not in * platform.ts): the snapshot-store engine (createSnapshotStore/defineStore/ @@ -61,19 +68,83 @@ function browserSourcePath(source: string, sourcemapPath: string): string { /** * Build the tsdown config for one UI plugin package: the node-half lib build - * plus the browser client bundle. A package-level tsdown.config.ts REPLACES - * the root workspace shape, so the lib half must be restated here — dropping - * it leaves the package without lib/index.js and the host Loader cannot - * import its node half. + * plus the browser client bundle. Client packages emit both halves during the + * Client pass by default; packages needed for Host reflection may opt into the + * earlier Host pass. A package-level tsdown.config.ts REPLACES the root + * workspace shape, so the lib half must be restated here — dropping it leaves + * the package without lib/index.js and the host Loader cannot import its node + * half. * @param id - plugin id (package name), stamped into the __ModuleLoader__.load * handoff and onto the injected style tags. * @param libEntry - node-half entries, spelled at the call site so the * package-invariants gate can see `lib/types/invariant.js` in each package's * own tsdown.config.ts (a preset-side glob hides it from the mechanical check). - * @returns tsdown user configs emitting lib/*.js and lib/client.js. + * @param options - phase placement, lib overrides, and companion Node configs. + * @returns ENV-selected tsdown config for the current build face. */ -export function clientBundle(id: string, libEntry: readonly string[]): [UserConfig, UserConfig] { - return [{ +export function clientBundle( + id: string, + libEntry: readonly string[], + options: ClientBundleOptions = {}, +): BuildFaceConfig { + const lib = clientLibraryConfig(id, libEntry, options.lib) + return ({ env }) => { + const face = buildFace(env?.DSH_BUILD_FACE) + const client = clientConfig(id, face === undefined + ? 'src/client/index.ts' + : 'lib/types/client/index.js') + const host = [lib, ...(options.host ?? [])] + if (face === 'host') return options.hostPhase === true ? host : [SKIP_WORKSPACE_BUILD] + if (face === 'client') return options.hostPhase === true ? [client] : [...host, client] + return [...host, client] + } +} + +/** + * Build a Client-only Node library during the Client pass. + * @param id - Package name used in tsdown diagnostics. + * @param libEntry - Emitted JavaScript entries consumed from `lib/types`. + * @returns ENV-selected tsdown config for the Client build face. + */ +export function clientLibrary(id: string, libEntry: readonly string[]): BuildFaceConfig { + const lib = clientLibraryConfig(id, libEntry) + return clientOnly([lib]) +} + +/** + * Select arbitrary package-local configs only during the Client pass. + * @param configs - Node-side configs emitted after Client tsc. + * @returns ENV-selected tsdown config for the Client build face. + */ +export function clientOnly(configs: readonly UserConfig[]): BuildFaceConfig { + return ({ env }) => buildFace(env?.DSH_BUILD_FACE) === 'host' + ? [SKIP_WORKSPACE_BUILD] + : [...configs] +} + +interface ClientBundleOptions { + /** Emit the Node-side artifacts during the Host pass instead of the Client pass. */ + readonly hostPhase?: boolean + readonly host?: readonly UserConfig[] + readonly lib?: UserConfig +} + +type BuildFace = 'host' | 'client' | undefined + +type BuildFaceConfig = (inlineConfig: Pick) => UserConfig[] + +function buildFace(value: unknown): BuildFace { + if (value === undefined || value === 'host' || value === 'client') return value + throw new Error(`tsdown: --env.DSH_BUILD_FACE must be host or client, received ${String(value)}`) +} + +function clientLibraryConfig( + id: string, + libEntry: readonly string[], + overrides: UserConfig = {}, +): UserConfig { + return { + name: id, entry: [...libEntry], outDir: 'lib', format: ['esm'], @@ -82,8 +153,14 @@ export function clientBundle(id: string, libEntry: readonly string[]): [UserConf fixedExtension: false, dts: false, clean: false, - }, { - entry: { client: 'src/client/index.ts' }, + ...overrides, + } +} + +function clientConfig(id: string, entry: string): UserConfig { + return { + name: `${id}/client`, + entry: { client: entry }, // Browser bundle lands next to the node half (single lib/ artifact dir; // the entryFileNames pin keeps it exactly lib/client.js). clean must stay // off — a default clean would wipe the node-half output emitted above. @@ -139,7 +216,7 @@ export function clientBundle(id: string, libEntry: readonly string[]): [UserConf name: 'dsh-css-modules-inline', resolveId(source: string, importer: string | undefined) { if (!source.endsWith('.module.css')) return null - const abs = importer !== undefined ? resolvePath(dirname(importer), source) : source + const abs = importer !== undefined ? sourceAssetPath(source, importer) : source return CSS_VIRTUAL_PREFIX + abs + CSS_VIRTUAL_SUFFIX }, async load(virtualId: string) { @@ -182,5 +259,15 @@ export function clientBundle(id: string, libEntry: readonly string[]): [UserConf footer: `return module.exports; } });`, intro: 'var module = { exports: {} }; var exports = module.exports;', }, - }] + } +} + +/** Resolve an emitted JS asset import against its source-tree counterpart. */ +function sourceAssetPath(source: string, importer: string): string { + const emitted = resolvePath(dirname(importer), source) + if (existsSync(emitted)) return emitted + const marker = `${sep}lib${sep}types${sep}` + const boundary = emitted.indexOf(marker) + if (boundary < 0) return emitted + return resolvePath(emitted.slice(0, boundary), 'src', emitted.slice(boundary + marker.length)) } diff --git a/packages/client/ui-goal/tsconfig.json b/packages/client/ui-goal/tsconfig.json index 263dfceb26..1c89771abf 100644 --- a/packages/client/ui-goal/tsconfig.json +++ b/packages/client/ui-goal/tsconfig.json @@ -15,7 +15,7 @@ "path": "../locale" }, { - "path": "../../api/remotes" + "path": "../../api/remotes/tsconfig.client.json" }, { "path": "../runtime" diff --git a/packages/client/ui-primitives/tsdown.config.ts b/packages/client/ui-primitives/tsdown.config.ts index 1532f5e5f6..cbabf4f2a1 100644 --- a/packages/client/ui-primitives/tsdown.config.ts +++ b/packages/client/ui-primitives/tsdown.config.ts @@ -1,4 +1,4 @@ -import { defineConfig } from 'tsdown' +import { clientOnly } from '../tsdown.client.ts' /** * ui-primitives is browser-only, but its lib bundle IS imported under plain @@ -8,7 +8,7 @@ import { defineConfig } from 'tsdown' * (loader module table / vite source paths), which compile src directly and * never read lib. */ -export default defineConfig({ +export default clientOnly([{ entry: ['lib/types/index.js', 'lib/types/invariant.js'], outDir: 'lib', format: ['esm'], @@ -28,4 +28,4 @@ export default defineConfig({ return 'export default {};' }, }], -}) +}]) diff --git a/packages/client/ui-slots/tsdown.config.ts b/packages/client/ui-slots/tsdown.config.ts new file mode 100644 index 0000000000..b199e31976 --- /dev/null +++ b/packages/client/ui-slots/tsdown.config.ts @@ -0,0 +1,6 @@ +import { clientLibrary } from '../tsdown.client.ts' + +export default clientLibrary( + '@deepseek-ai/dsh-client-ui-slots', + ['lib/types/index.js', 'lib/types/invariant.js'], +) diff --git a/packages/client/ui-theme/tsdown.config.ts b/packages/client/ui-theme/tsdown.config.ts index 08616753ce..25b80eef68 100644 --- a/packages/client/ui-theme/tsdown.config.ts +++ b/packages/client/ui-theme/tsdown.config.ts @@ -1,11 +1,11 @@ import { clientBundle } from '../tsdown.client.ts' -const [lib, client] = clientBundle( +export default clientBundle( '@deepseek-ai/dsh-client-ui-theme', ['lib/types/index.js', 'lib/types/invariant.js'], + { + lib: { + copy: [{ from: 'src/styles/*', to: 'lib/styles' }], + }, + }, ) - -export default [{ - ...lib, - copy: [{ from: 'src/styles/*', to: 'lib/styles' }], -}, client] diff --git a/packages/client/web-react/tsdown.config.ts b/packages/client/web-react/tsdown.config.ts index 65378be678..676d6ce415 100644 --- a/packages/client/web-react/tsdown.config.ts +++ b/packages/client/web-react/tsdown.config.ts @@ -1,4 +1,4 @@ -import { defineConfig } from 'tsdown' +import { clientOnly } from '../tsdown.client.ts' /** * Root and invariant shapes as SEPARATE single-entry bundles: a multi-entry @@ -8,7 +8,7 @@ import { defineConfig } from 'tsdown' * runtime — browser consumers resolve this package through the loader module * table. */ -export default defineConfig([ +export default clientOnly([ { entry: { index: 'lib/types/index.js' }, outDir: 'lib', diff --git a/packages/client/web/tsdown.config.ts b/packages/client/web/tsdown.config.ts index 8040221e14..78527fba80 100644 --- a/packages/client/web/tsdown.config.ts +++ b/packages/client/web/tsdown.config.ts @@ -1,4 +1,4 @@ -import { defineConfig } from 'tsdown' +import { clientOnly } from '../tsdown.client.ts' /** * Root-shape lib build plus a css stub: the shell's components import @@ -8,7 +8,7 @@ import { defineConfig } from 'tsdown' * this node lib build stubs every css import to an empty module — importing * the lib under plain node must not crash on an asset specifier. */ -export default defineConfig({ +export default clientOnly([{ entry: ['lib/types/index.js', 'lib/types/invariant.js'], outDir: 'lib', format: ['esm'], @@ -28,4 +28,4 @@ export default defineConfig({ return 'export default {};' }, }], -}) +}]) diff --git a/packages/host/apiproxy/tsconfig.json b/packages/host/apiproxy/tsconfig.json index 912f2cd794..8686a9e468 100644 --- a/packages/host/apiproxy/tsconfig.json +++ b/packages/host/apiproxy/tsconfig.json @@ -24,7 +24,7 @@ "path": "../../../vendor/schemastery" }, { - "path": "../../api/remotes" + "path": "../../api/remotes/tsconfig.host.json" }, { "path": "../../util/brand" diff --git a/packages/host/directory-picker-native/tsdown.config.ts b/packages/host/directory-picker-native/tsdown.config.ts index 529fb7b2ac..4a4727a5aa 100644 --- a/packages/host/directory-picker-native/tsdown.config.ts +++ b/packages/host/directory-picker-native/tsdown.config.ts @@ -3,18 +3,21 @@ import { clientBundle } from '../../client/tsdown.client.ts' // The Win32 dialog worker builds as its own CJS entry (mirroring // dsh-workflow-workerthread's worker): path-loaded by the driver, inlining // the dialog logic while koffi stays an external native require. -export default [ - ...clientBundle('@deepseek-ai/dsh-host-directory-picker-native', ['lib/types/index.js', 'lib/types/invariant.js']), +export default clientBundle( + '@deepseek-ai/dsh-host-directory-picker-native', + ['lib/types/index.js', 'lib/types/invariant.js'], { - // The artifact is lib/worker.cjs (the ./worker export the workspace - // constraint keys on), bundled from the descriptive source entry. - entry: { worker: 'lib/types/win32-dialog-worker.js' }, - outDir: 'lib', - format: ['cjs'] as ['cjs'], - platform: 'node' as const, - target: 'es2024', - fixedExtension: false, - dts: false, - clean: false, + host: [{ + // The artifact is lib/worker.cjs (the ./worker export the workspace + // constraint keys on), bundled from the descriptive source entry. + entry: { worker: 'lib/types/win32-dialog-worker.js' }, + outDir: 'lib', + format: ['cjs'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + }], }, -] +) diff --git a/packages/typert/generator/src/analyzer.ts b/packages/typert/generator/src/analyzer.ts index c5d89b3726..6f495aa318 100644 --- a/packages/typert/generator/src/analyzer.ts +++ b/packages/typert/generator/src/analyzer.ts @@ -476,11 +476,20 @@ export class WorkspaceAnalyzer { config: this.caches.config(configPath), manifest, } - if (isDualFacePackage(manifest)) { - registrations.push({ ...registration, face: 'host', exportSubpaths: hostExportSubpaths(manifest) }) - registrations.push({ ...registration, face: 'client', exportSubpaths: clientExportSubpaths(manifest) }) - } else { + if (!isDualFacePackage(manifest)) { registrations.push(registration) + } else if (configPath === join(packageRoot, 'tsconfig.json')) { + registrations.push( + { ...registration, face: 'host', exportSubpaths: hostExportSubpaths(manifest) }, + { ...registration, face: 'client', exportSubpaths: clientExportSubpaths(manifest) }, + ) + } else { + registrations.push({ + ...registration, + exportSubpaths: face === 'host' + ? hostExportSubpaths(manifest) + : clientExportSubpaths(manifest), + }) } } } diff --git a/packages/typert/generator/tests/type-model.spec.ts b/packages/typert/generator/tests/type-model.spec.ts index ca37cd1bbe..40a91e3ef5 100644 --- a/packages/typert/generator/tests/type-model.spec.ts +++ b/packages/typert/generator/tests/type-model.spec.ts @@ -864,6 +864,31 @@ describe('WorkspaceAnalyzer', { timeout: 60_000 }, () => { .toEqual(['@fixture/host']) }) + it('keeps both runtime faces for an ordinary dshClient project', () => { + const root = copyFixture('typert-dual-runtime-') + configureDualRuntimeClient(root, false) + + expect(new WorkspaceAnalyzer({ root }).discoverPackages()).toContainEqual({ + package: '@fixture/client', + root: 'packages/client', + faces: ['client', 'host'], + }) + }) + + it('confines explicit face projects to their selected TypeRT face', () => { + const root = copyFixture('typert-split-project-') + configureDualRuntimeClient(root, true) + + const markers = new WorkspaceAnalyzer({ root }).indexSourceDeclarations() + .filter(declaration => declaration.package === '@fixture/client' + && declaration.name.endsWith('OnlyMarker')) + .map(declaration => ({ face: declaration.face, name: declaration.name })) + expect(markers).toEqual([ + { face: 'client', name: 'ClientOnlyMarker' }, + { face: 'host', name: 'HostOnlyMarker' }, + ]) + }) + it('accepts package export forms while skipping artifact-only rows and unexported packages', { timeout: 180_000 }, () => { const root = copyFixture('typert-export-forms-') const hostRoot = join(root, 'packages/host') @@ -1193,6 +1218,63 @@ function copyFixture(prefix: string): string { return root } +function configureDualRuntimeClient(root: string, splitProjects: boolean): void { + const packageRoot = join(root, 'packages/client') + const manifestPath = join(packageRoot, 'package.json') + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { + dshClient?: object + exports: Record + } + manifest.dshClient = {} + manifest.exports['./client'] = { + types: './lib/types/client.d.ts', + default: './lib/client.js', + } + writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`) + writeFileSync(join(packageRoot, 'src/client.ts'), [ + "import { Service } from 'cordis'", + 'export interface ClientOnlyMarker { readonly client: true }', + 'export class BrowserBridge extends Service {}', + "declare module 'cordis' { interface Context { browserBridge: BrowserBridge } }", + '', + ].join('\n')) + const indexPath = join(packageRoot, 'src/index.ts') + writeFileSync(indexPath, `${readFileSync(indexPath, 'utf8')}\nexport interface HostOnlyMarker { readonly host: true }\n`) + if (!splitProjects) return + + const project = JSON.parse(readFileSync(join(packageRoot, 'tsconfig.json'), 'utf8')) as Record + delete project.include + writeFileSync(join(packageRoot, 'tsconfig.host.json'), `${JSON.stringify({ + ...project, + files: ['src/index.ts'], + }, null, 2)}\n`) + writeFileSync(join(packageRoot, 'tsconfig.client.json'), `${JSON.stringify({ + ...project, + files: ['src/client.ts'], + }, null, 2)}\n`) + writeFileSync(join(packageRoot, 'tsconfig.json'), `${JSON.stringify({ + files: [], + references: [ + { path: './tsconfig.host.json' }, + { path: './tsconfig.client.json' }, + ], + }, null, 2)}\n`) + + const hostAggregatePath = join(root, 'tsconfig.host.json') + const hostAggregate = JSON.parse(readFileSync(hostAggregatePath, 'utf8')) as { + references: { path: string }[] + } + hostAggregate.references.push({ path: './packages/client/tsconfig.host.json' }) + writeFileSync(hostAggregatePath, `${JSON.stringify(hostAggregate, null, 2)}\n`) + + const clientAggregatePath = join(root, 'tsconfig.client.json') + const clientAggregate = JSON.parse(readFileSync(clientAggregatePath, 'utf8')) as { + references: { path: string }[] + } + clientAggregate.references = [{ path: './packages/client/tsconfig.client.json' }] + writeFileSync(clientAggregatePath, `${JSON.stringify(clientAggregate, null, 2)}\n`) +} + function addSameFacePackage(root: string, specifier: string, importedName: string): void { const packageRoot = join(root, 'packages/consumer') mkdirSync(join(packageRoot, 'src'), { recursive: true }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 078f775ecf..4e50be7e35 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1446,9 +1446,6 @@ importers: specifier: ~4.4.7 version: 4.4.7(@types/react@18.3.31)(immer@10.2.0)(react@18.3.1) devDependencies: - '@deepseek-ai/dsh-api-remotes': - specifier: workspace:^ - version: link:../../api/remotes '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants diff --git a/scripts/client-bundle-css.spec.ts b/scripts/client-bundle-css.spec.ts index 30350241fc..41b3eb3679 100644 --- a/scripts/client-bundle-css.spec.ts +++ b/scripts/client-bundle-css.spec.ts @@ -15,8 +15,13 @@ interface CssPlugin { } function cssPlugin(): CssPlugin { - const configs = clientBundle('@deepseek-ai/dsh-client-test', ['lib/types/index.js', 'lib/types/invariant.js']) - const plugins = (configs[1] as { plugins: CssPlugin[] }).plugins + const configs = clientBundle( + '@deepseek-ai/dsh-client-test', + ['lib/types/index.js', 'lib/types/invariant.js'], + )({ env: { DSH_BUILD_FACE: 'client' } }) + const client = configs.find(config => config.platform === 'browser') + if (client === undefined) throw new Error('client config missing') + const plugins = (client as { plugins: CssPlugin[] }).plugins const plugin = plugins.find(candidate => candidate.name === 'dsh-css-modules-inline') if (plugin === undefined) throw new Error('CSS Modules plugin missing from client config') return plugin diff --git a/scripts/client-bundle-purity.spec.ts b/scripts/client-bundle-purity.spec.ts index fb47f8a9c8..aa3bae0b6d 100644 --- a/scripts/client-bundle-purity.spec.ts +++ b/scripts/client-bundle-purity.spec.ts @@ -14,6 +14,24 @@ interface CssModulePlugin { load?: (this: { addWatchFile: (id: string) => void }, id: string) => Promise } +function clientConfigs(id = '@deepseek-ai/dsh-client-test') { + return clientBundle(id, ['lib/types/index.js', 'lib/types/invariant.js'])( + { env: { DSH_BUILD_FACE: 'client' } }, + ).filter(config => config.platform === 'browser') +} + +describe('client bundle build faces', () => { + it('watches source in development and consumes emitted JavaScript in the Client build', () => { + const bundle = clientBundle('@deepseek-ai/dsh-client-test', ['lib/types/index.js']) + const development = bundle({ env: {} }).find(config => config.platform === 'browser') + const artifact = bundle({ env: { DSH_BUILD_FACE: 'client' } }) + .find(config => config.platform === 'browser') + + expect(development?.entry).toEqual({ client: 'src/client/index.ts' }) + expect(artifact?.entry).toEqual({ client: 'lib/types/client/index.js' }) + }) +}) + function clientSourceMapPath(packagePath: string): string { return fileURLToPath(new URL(`../packages/${packagePath}/lib/client.js.map`, import.meta.url)) } @@ -21,16 +39,16 @@ function clientSourceMapPath(packagePath: string): string { function purityResolveId(): ResolveId { // libEntry is spelled at every call site (no default) so the // package-invariants text check can see the invariant entry per package. - const configs = clientBundle('@deepseek-ai/dsh-client-test', ['lib/types/index.js', 'lib/types/invariant.js']) - const plugins = (configs[1] as { plugins: { name: string; resolveId?: unknown }[] }).plugins + const configs = clientConfigs() + const plugins = (configs[0] as { plugins: { name: string; resolveId?: unknown }[] }).plugins const gate = plugins.find(p => p.name === 'dsh-client-bundle-purity') if (gate?.resolveId === undefined) throw new Error('purity plugin missing from client config') return gate.resolveId as ResolveId } function cssModulePlugin(): CssModulePlugin { - const configs = clientBundle('@deepseek-ai/dsh-client-test', ['lib/types/index.js', 'lib/types/invariant.js']) - const plugins = (configs[1] as { plugins: CssModulePlugin[] }).plugins + const configs = clientConfigs() + const plugins = (configs[0] as { plugins: CssModulePlugin[] }).plugins const plugin = plugins.find(candidate => candidate.name === 'dsh-css-modules-inline') if (plugin?.resolveId === undefined || plugin.load === undefined) { throw new Error('CSS Modules plugin missing from client config') @@ -87,13 +105,13 @@ describe('client bundle purity gate', () => { describe('client bundle debug artifacts', () => { it('emits source maps for plugin TS and TSX outside the Vite module graph', () => { - const configs = clientBundle('@deepseek-ai/dsh-client-test', ['lib/types/index.js', 'lib/types/invariant.js']) - expect(configs[1]?.sourcemap).toBe(true) + const configs = clientConfigs() + expect(configs[0]?.sourcemap).toBe(true) }) it('maps first-party sources to their repository package paths', () => { - const configs = clientBundle('@deepseek-ai/dsh-client-ui-goal', ['lib/types/index.js', 'lib/types/invariant.js']) - const outputOptions = configs[1]?.outputOptions + const configs = clientConfigs('@deepseek-ai/dsh-client-ui-goal') + const outputOptions = configs[0]?.outputOptions if (typeof outputOptions !== 'object' || outputOptions === null) throw new Error('client output options missing') const transform = outputOptions.sourcemapPathTransform if (transform === undefined) throw new Error('client sourcemap path transform missing') @@ -105,8 +123,8 @@ describe('client bundle debug artifacts', () => { }) it('maps dual-face host sources to the host package group', () => { - const configs = clientBundle('@deepseek-ai/dsh-host-directory-picker-native', ['lib/types/index.js']) - const outputOptions = configs[1]?.outputOptions + const configs = clientConfigs('@deepseek-ai/dsh-host-directory-picker-native') + const outputOptions = configs[0]?.outputOptions if (typeof outputOptions !== 'object' || outputOptions === null) throw new Error('client output options missing') const transform = outputOptions.sourcemapPathTransform if (transform === undefined) throw new Error('client sourcemap path transform missing') @@ -116,8 +134,8 @@ describe('client bundle debug artifacts', () => { }) it('maps inlined workspace sources to packages and leaves dependencies outside it unchanged', () => { - const configs = clientBundle('@deepseek-ai/dsh-client-connection', ['lib/types/index.js']) - const outputOptions = configs[1]?.outputOptions + const configs = clientConfigs('@deepseek-ai/dsh-client-connection') + const outputOptions = configs[0]?.outputOptions if (typeof outputOptions !== 'object' || outputOptions === null) throw new Error('client output options missing') const transform = outputOptions.sourcemapPathTransform if (transform === undefined) throw new Error('client sourcemap path transform missing') diff --git a/scripts/doc-typecheck.ts b/scripts/doc-typecheck.ts index 6ae08150fb..456dedecfe 100644 --- a/scripts/doc-typecheck.ts +++ b/scripts/doc-typecheck.ts @@ -136,22 +136,25 @@ function formatDiagnostics(diagnostics: readonly ts.Diagnostic[], blocks: Block[ } /** - * Reuse the host-aggregate references from a temp project one directory below - * root. Doc fragments speak the host vocabulary, so the standalone project - * seeds tsconfig.host.json (never the root solution: flattening host+client - * into one program collides the cordis Context merges). + * Reuse both aggregate reference sets from a temp project one directory below + * root. Each referenced package remains its own program, while documentation + * examples can import either the Host or Client API. */ function workspaceReferences(): { path: string }[] { - const file = join(root, 'tsconfig.host.json') - // Parse with TypeScript's own JSONC reader: a regex comment stripper corrupts the `/*/` path - // candidate in the workspace wildcard. - const result = ts.readConfigFile(file, path => readFileSync(path, 'utf8')) - if (result.error) { - throw new Error(`doc-typecheck: cannot read ${file}: ${ts.flattenDiagnosticMessageText(result.error.messageText, '\n')}`) + const paths = new Set() + for (const aggregate of ['tsconfig.host.json', 'tsconfig.client.json']) { + const file = join(root, aggregate) + // Parse with TypeScript's own JSONC reader: a regex comment stripper corrupts the `/*/` path + // candidate in the workspace wildcard. + const result = ts.readConfigFile(file, path => readFileSync(path, 'utf8')) + if (result.error) { + throw new Error(`doc-typecheck: cannot read ${file}: ${ts.flattenDiagnosticMessageText(result.error.messageText, '\n')}`) + } + // `config` is typed `any` by the TS API; narrow it to the one field read here. + const { references } = result.config as { references: { path: string }[] } + for (const { path } of references) paths.add(path) } - // `config` is typed `any` by the TS API; narrow it to the one field read here. - const { references } = result.config as { references: { path: string }[] } - return references.map(({ path }) => ({ + return [...paths].map(path => ({ path: path.startsWith('./') ? `../${path.slice(2)}` : `../${path}`, })) } diff --git a/scripts/package-invariants.spec.ts b/scripts/package-invariants.spec.ts index 32705c4c87..59e56976e6 100644 --- a/scripts/package-invariants.spec.ts +++ b/scripts/package-invariants.spec.ts @@ -72,6 +72,20 @@ describe('package invariant gate', () => { expect(collectPackageInvariantViolations(fixture())).toEqual([]) }) + it('accepts an invariant reference owned by a package-local leaf project', () => { + const root = fixture({ invariantReference: false }) + const dir = join(root, 'packages/core/probe') + writeFileSync(join(dir, 'tsconfig.json'), `${JSON.stringify({ + files: [], + references: [{ path: './tsconfig.host.json' }], + }, null, 2)}\n`) + writeFileSync(join(dir, 'tsconfig.host.json'), `${JSON.stringify({ + references: [{ path: '../../support/invariants' }], + }, null, 2)}\n`) + + expect(collectPackageInvariantViolations(root)).toEqual([]) + }) + it('rejects missing publication metadata and build output', () => { const violations = collectPackageInvariantViolations(fixture({ invariantExport: false, diff --git a/scripts/package-invariants.ts b/scripts/package-invariants.ts index 21bc5931ec..54318ca94f 100644 --- a/scripts/package-invariants.ts +++ b/scripts/package-invariants.ts @@ -118,11 +118,8 @@ function checkBuild( violations: PackageInvariantViolation[], ): void { const tsconfigPath = `${owner.dir}/tsconfig.json` - const tsconfig = JSON.parse(readFileSync(resolve(root, tsconfigPath), 'utf8')) as { - references?: Array<{ path?: string }> - } if (owner.packageName !== '@deepseek-ai/dsh-invariants' - && !tsconfig.references?.some(reference => reference.path === '../../support/invariants')) { + && !projectReferencesInvariants(root, owner.dir, tsconfigPath)) { addViolation( violations, tsconfigPath, @@ -138,6 +135,31 @@ function checkBuild( } } +function projectReferencesInvariants(root: string, ownerDir: string, entryPath: string): boolean { + const ownerRoot = resolve(root, ownerDir) + const target = resolve(root, 'packages/support/invariants') + const pending = [resolve(root, entryPath)] + const visited = new Set() + while (pending.length > 0) { + const configPath = pending.pop() + if (configPath === undefined) break + if (visited.has(configPath)) continue + visited.add(configPath) + const config = JSON.parse(readFileSync(configPath, 'utf8')) as { + references?: Array<{ path?: string }> + } + for (const reference of config.references ?? []) { + if (reference.path === undefined) continue + const referenced = resolve(dirname(configPath), reference.path) + if (referenced === target) return true + if (!referenced.startsWith(`${ownerRoot}${sep}`)) continue + const childConfig = referenced.endsWith('.json') ? referenced : resolve(referenced, 'tsconfig.json') + if (existsSync(childConfig)) pending.push(childConfig) + } + } + return false +} + function checkSource( owner: PackageInvariantOwner, root: string, diff --git a/scripts/wine-windows-gates.sh b/scripts/wine-windows-gates.sh index 1f5de1dcbd..b6d8ffffb0 100755 --- a/scripts/wine-windows-gates.sh +++ b/scripts/wine-windows-gates.sh @@ -204,15 +204,14 @@ cat "$scratch/logs/smoke.log" grep -q '^smoke: win32 x64' "$scratch/logs/smoke.log" || { echo 'wine-windows-gates: Windows Node smoke did not report win32 x64' >&2; exit 1; } # ---- the two blocking surfaces, concurrently ------------------------------ -# The build preserves the face order from package.json: generate Host contracts -# before either aggregate typecheck, then bundle the completed workspace. +# The build preserves the face order from package.json: compile and bundle the +# Host face before compiling and bundling the Client face. # Both statuses are captured so one failure cannot hide the other's result. build_gate() { - wine_node "$scratch/logs/contracts-tsc.log" "$tsc_js" -b packages/typert/generator --pretty false || return $? - wine_node "$scratch/logs/contracts-tsdown.log" "$tsdown_js" --config tsdown.typert-host.config.ts || return $? wine_node "$scratch/logs/host-tsc.log" "$tsc_js" -b tsconfig.host.json --pretty false || return $? + wine_node "$scratch/logs/host-tsdown.log" "$tsdown_js" --env.DSH_BUILD_FACE host || return $? wine_node "$scratch/logs/client-tsc.log" "$tsc_js" -b tsconfig.client.json --pretty false || return $? - wine_node "$scratch/logs/tsdown.log" "$tsdown_js" + wine_node "$scratch/logs/client-tsdown.log" "$tsdown_js" --env.DSH_BUILD_FACE client } site_gate() { cd website @@ -238,12 +237,11 @@ report() { for log in "$@"; do tail -n 200 "$log" >&2 || true; done fi } -report 'build (contract prepass, tsc, tsdown)' "$build_status" \ - "$scratch/logs/contracts-tsc.log" \ - "$scratch/logs/contracts-tsdown.log" \ +report 'build (Host tsc/tsdown, Client tsc/tsdown)' "$build_status" \ "$scratch/logs/host-tsc.log" \ + "$scratch/logs/host-tsdown.log" \ "$scratch/logs/client-tsc.log" \ - "$scratch/logs/tsdown.log" + "$scratch/logs/client-tsdown.log" report 'production site (vitepress build)' "$site_status" "$scratch/logs/site.log" if (( build_status != 0 )); then exit "$build_status"; fi exit "$site_status" diff --git a/tsconfig.client.json b/tsconfig.client.json index 9821c0e41b..2a2b16e2e7 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -53,7 +53,7 @@ { "path": "./packages/client/connection" }, { "path": "./packages/typert/registry" }, { "path": "./packages/api/gateway" }, - { "path": "./packages/api/remotes" }, + { "path": "./packages/api/remotes/tsconfig.client.json" }, { "path": "./packages/client/runtime" }, { "path": "./packages/client/test-runtime" }, { "path": "./packages/client/ui-layout" }, diff --git a/tsconfig.host.json b/tsconfig.host.json index 61e42e2fc6..7e7cd982fc 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -104,6 +104,7 @@ { "path": "./packages/typert/type-meta" }, { "path": "./packages/typert/registry" }, { "path": "./packages/api/gateway" }, + { "path": "./packages/api/remotes/tsconfig.host.json" }, { "path": "./packages/typert/loader" }, { "path": "./packages/session-persistence/session-persistence" }, { "path": "./packages/session-persistence/session-checkpoint-policy" }, diff --git a/tsdown.config.ts b/tsdown.config.ts index 0d503c62d3..2042e81db3 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -1,34 +1,30 @@ import { defineConfig } from 'tsdown' import { typertPlugin } from './packages/typert/generator/lib/types/tsdown-plugin.js' +function isBuildFaceClient(value: unknown): boolean { + if (value === undefined || value === 'host') return false + if (value === 'client') return true + throw new Error(`tsdown: --env.DSH_BUILD_FACE must be host or client, received ${String(value)}`) +} + /** - * JS bundling for vendored Cordis and Harness TypeScript packages. - * TypeScript source is compiled first by `tsc -b` (the root solution); tsdown - * reads only the emitted JS under lib/types and writes the package root and - * invariant companion runtime bundles. Declarations are NOT produced here, - * hence `dts: false`. - * - * Per-package shape overrides live in `/tsdown.config.ts` - * (schemastery: dual ESM+CJS; logger-console: extra browser entry). + * The ordinary workspace build consumes JavaScript emitted by the Host + * TypeScript project and runs TypeRT. The Client pass selects packages that + * declare a browser bundle and lets their package-local configs emit both + * their Node loader entry and browser artifact. */ -export default defineConfig({ - // Explicit globs keep bundling to vendored Cordis, the TypeScript package tree, and - // the Node CLI assembly. `apps/web` is a Vite application with no lib/types entry; - // `workspace: true` or `apps/*` would incorrectly treat it as a package bundle. - workspace: ['vendor/*', 'packages/*/*', 'apps/cli'], - // The brace glob admits the package companion when present while retaining the - // index-only build for vendored Cordis packages outside the Harness package tree. - entry: ['lib/types/{index,invariant}.js'], - outDir: 'lib', - format: ['esm'], - platform: 'node', - target: 'es2024', - // All packages set "type": "module"; fixedExtension false keeps ESM output - // at .js (not .mjs), matching the package.json main/exports fields. - fixedExtension: false, - dts: false, - clean: false, - // The final pass sees both independent TypeScript faces. Workspace mode - // writes only packages that explicitly publish a Typert/Remote subpath. - plugins: [typertPlugin({ mode: 'workspace' })], +export default defineConfig(({ env }) => { + const client = isBuildFaceClient(env?.DSH_BUILD_FACE) + return { + workspace: ['vendor/*', 'packages/*/*', 'apps/cli'], + entry: client ? '' : ['lib/types/{index,invariant}.js'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + plugins: client ? [] : [typertPlugin({ mode: 'workspace', faces: ['host'] })], + } }) diff --git a/tsdown.typert-host.config.ts b/tsdown.typert-host.config.ts deleted file mode 100644 index 8c8ae11dd1..0000000000 --- a/tsdown.typert-host.config.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { defineConfig } from 'tsdown' -import { typertPlugin } from './packages/typert/generator/lib/types/tsdown-plugin.js' - -/** - * Host-only TypeRT contract prepass. The generator and its project references - * are compiled first; the plugin then analyzes Host source and emits local and - * Host-for-Client artifacts before either aggregate consumes Remote subpaths. - */ -export default defineConfig({ - workspace: ['packages/typert/generator'], - entry: ['lib/types/{index,invariant}.js'], - outDir: 'lib', - format: ['esm'], - platform: 'node', - target: 'es2024', - fixedExtension: false, - dts: false, - clean: false, - plugins: [typertPlugin({ mode: 'workspace', faces: ['host'] })], -}) From 8865548ee2397927866e0ca6ddc6d777069f6af4 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 8 Aug 2026 03:41:35 +0800 Subject: [PATCH 33/37] docs: explain generated Remote build order --- .../2026-06-17-ts-build-config.i18n.yaml | 4 +- .../process/2026-06-17-ts-build-config.md | 29 +- .../process/2026-06-17-ts-build-config.zh.md | 29 +- ...fig-solution-root-two-aggregates.i18n.yaml | 6 +- ...2-tsconfig-solution-root-two-aggregates.md | 6 +- ...sconfig-solution-root-two-aggregates.zh.md | 6 +- ...remotes-generated-contract-build.i18n.yaml | 6 + ...08-api-remotes-generated-contract-build.md | 80 ++++ ...api-remotes-generated-contract-build.zh.md | 80 ++++ AGENTS.md | 2 +- docs/api-gateway.i18n.yaml | 4 +- docs/api-gateway.md | 12 +- docs/api-gateway.zh.md | 12 +- docs/cookbook/adding-a-package.i18n.yaml | 4 +- docs/cookbook/adding-a-package.md | 2 +- docs/cookbook/adding-a-package.zh.md | 2 +- docs/development.i18n.yaml | 4 +- docs/development.md | 35 +- docs/development.zh.md | 35 +- docs/module-graph.md | 343 +++++++++--------- packages/AGENTS.md | 2 +- packages/api/remotes/README.i18n.yaml | 4 +- packages/api/remotes/README.md | 8 + packages/api/remotes/README.zh.md | 8 + packages/typert/generator/README.i18n.yaml | 4 +- packages/typert/generator/README.md | 4 +- packages/typert/generator/README.zh.md | 4 +- .../request-response.expected.json | 4 +- 28 files changed, 480 insertions(+), 259 deletions(-) create mode 100644 .agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md create mode 100644 .agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.zh.md diff --git a/.agents/notes/implemented/process/2026-06-17-ts-build-config.i18n.yaml b/.agents/notes/implemented/process/2026-06-17-ts-build-config.i18n.yaml index fe42a92f75..a126f8c0d0 100644 --- a/.agents/notes/implemented/process/2026-06-17-ts-build-config.i18n.yaml +++ b/.agents/notes/implemented/process/2026-06-17-ts-build-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/process/2026-06-17-ts-build-config.md -2026-06-17-ts-build-config.md: ec4c6a4aeb1074a69d45b2cf4b4c733b410ccceb -2026-06-17-ts-build-config.zh.md: 8115bb2557eea967d57eb6693e2bb288188792a8 +2026-06-17-ts-build-config.md: f25731921aaa6da7a4c9760244f73dc0670dc1d3 +2026-06-17-ts-build-config.zh.md: 41791129647b6a5bfeb966e05ee2c5b9132c45ef diff --git a/.agents/notes/implemented/process/2026-06-17-ts-build-config.md b/.agents/notes/implemented/process/2026-06-17-ts-build-config.md index ec4c6a4aeb..f25731921a 100644 --- a/.agents/notes/implemented/process/2026-06-17-ts-build-config.md +++ b/.agents/notes/implemented/process/2026-06-17-ts-build-config.md @@ -4,7 +4,7 @@ Status: implemented English | [中文](2026-06-17-ts-build-config.zh.md) -> Root project topology (which tsconfig owns which graph) has since moved to a solution root over two aggregate programs; see the [solution-root note](2026-07-22-tsconfig-solution-root-two-aggregates.md). The tsc-first pipeline decided here is unchanged. +> Root project topology uses a solution root over two aggregate programs; see the [solution-root note](2026-07-22-tsconfig-solution-root-two-aggregates.md). The [API Remotes build note](2026-08-08-api-remotes-generated-contract-build.md) defines the current command order in which the Host generates Remote contracts before the Client compiles. The tsc-first ownership decided here is unchanged. ## Problem @@ -30,18 +30,15 @@ Validation found several concrete technical issues and possible routes: In-package relative imports use explicit `.ts` specifiers. -`pnpm run build` is a two-stage build: +`pnpm run build` orders Host lib, Client lib, and Web; each lib phase keeps tsc emission before tsdown bundling: -- Stage 1: `tsc -b` over the root solution emits per-module `.js`, declarations `.d.ts`, JS sourcemaps `.js.map`, and declaration sourcemaps `.d.ts.map` into each package's `lib/types`. This is the authoritative TypeScript compilation result. Publication keeps `.d.ts`; packages whose runtime exports explicitly point into the emitted tree also keep its `.js` files. `.js.map` and `.d.ts.map` remain in the local build tree. - - The graph is the project-reference graph reachable from the root solution `tsconfig.json` through the two aggregates ([topology](2026-07-22-tsconfig-solution-root-two-aggregates.md)). It validates and emits package/vendor build results. -- Stage 2: a bundler reads the emitted JS under `lib/types` and writes the bundled runtime entry as `lib/index.js` or `lib/index.mjs` (follow current behavior). This stage is bundling only. It must not read TypeScript source or emit declarations. +- Host tsc runs `tsc -b` against `tsconfig.host.json`, emitting per-module `.js`, `.d.ts`, `.js.map`, and `.d.ts.map` into `lib/types` for each package in the Host graph; Host tsdown then reads that JavaScript, produces published entries, and runs Host TypeRT. +- Client tsc runs `tsc -b` against `tsconfig.client.json` after Host TypeRT has generated the Remote Client declarations; Client tsdown then reads the JavaScript emitted by the Client graph and produces the Client packages' Node loader entries and browser bundles. +- The Web build starts only after both lib phases complete. `tsdown` is no longer the owner of TypeScript compilation or declaration output. -`pnpm run typecheck` runs the same `tsc -b` graph. -- The aggregates (`tsconfig.host.json`, `tsconfig.client.json`) typecheck examples, tests, and scripts with `noEmit`, and validate package/vendor source through references. -- Referenced package/vendor projects keep the same emit behavior as build, so typecheck refreshes their `lib/types` outputs instead of using a separate no-emit graph. Project-specific strictness changes live in the owning `packages/*/*/tsconfig.json` or `vendor/*/tsconfig.json`. -- The no-emit aggregates disable `rewriteRelativeImportExtensions`; they emit nothing and include tests that import helpers across project-reference boundaries. Package/vendor emit projects keep the rewrite enabled. +`pnpm run typecheck` first runs the Host lib phase to generate the Remote declarations required by Client typechecking, then runs `tsc -b` against `tsconfig.client.json`. The two aggregates themselves check their respective examples, tests, and scripts with `noEmit`; referenced package/vendor projects retain the same emit behavior as the build. Composite projects keep their incremental build information inside their project-local `lib/` output. `pnpm run clean` derives live output directories from the root TypeScript project-reference graph, removes legacy root build information, and removes deleted `packages/*/*` directories that contain only known generated residue. Before removing an existing target, it resolves the target's parent and refuses it if that resolved parent is outside the repository, so a symlinked project reference cannot redirect cleanup outside the checkout. It preserves `node_modules` for every package that still has a `package.json`, and refuses to remove a manifest-less directory containing unknown files. Build does not invoke clean automatically, so ordinary builds retain incremental state. @@ -49,14 +46,18 @@ The command orchestration shape is: ```sh pnpm run build: -tsc -b -tsdown +tsc -b tsconfig.host.json +tsdown --env.DSH_BUILD_FACE host +tsc -b tsconfig.client.json +tsdown --env.DSH_BUILD_FACE client +pnpm run build:web pnpm run verify-node-next-types: tsx scripts/verify-node-next-types.ts pnpm run typecheck: -tsc -b +pnpm run build:lib:host +tsc -b tsconfig.client.json pnpm run clean: tsx scripts/clean.ts @@ -75,8 +76,8 @@ The source-mode demos run through their declared TypeScript launchers and the ro Build responsibilities are clearer: -- Each module under `packages//` and `vendor/*` has one local tsconfig for build, typecheck, and tools that run source directly, such as the `dsh` source loader, `tsx`, and `vitest`. -- The `build` command drives the root solution graph. `tsc -b` owns the publishable per-module `.js` and `.d.ts` output, and the bundler owns only `lib/index.*`. +- Each ordinary module under `packages//` and `vendor/*` has one local tsconfig for build, typecheck, and tools that run source directly, such as the `dsh` source loader, `tsx`, and `vitest`. `api/remotes` is the sole exception: generated-contract ordering requires one solution and two mutually exclusive emitting projects. +- The `build` command runs the Host and Client Project Reference graphs in order. In each phase, `tsc -b` owns the publishable per-module `.js` and `.d.ts` output, while the bundler owns only the published runtime bundles. - `lib/types/*.d.ts` is the publish declaration output; `.d.ts.map` remains only as a local compilation artifact. - `lib/types/*.d.ts` uses explicit `.ts` relative specifiers, which TypeScript's NodeNext/Node16 resolver maps to sibling `.d.ts` files. - `lib/types/*.js` is normally only a bundler input. It is published only when an explicit runtime export points into the emitted tree. diff --git a/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md b/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md index 8115bb2557..4179112964 100644 --- a/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md +++ b/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-06-17-ts-build-config.md) | 中文 -> 根项目拓扑(即哪个 tsconfig 拥有哪张图)后来改为由一个 solution 根文件统辖两个聚合 program;见[solution 根文件 Agent Note](2026-07-22-tsconfig-solution-root-two-aggregates.md)。本文确定的 TSC 优先流水线保持不变。 +> 根项目拓扑由一个 solution 根文件统辖两个 aggregate program;见 [solution 根文件 Agent Note](2026-07-22-tsconfig-solution-root-two-aggregates.md)。Host 生成 Remote 契约后再编译 Client 的当前命令顺序见 [API Remotes 构建 Agent Note](2026-08-08-api-remotes-generated-contract-build.md)。本文确定的 tsc-first 职责保持不变。 ## 问题 @@ -30,18 +30,15 @@ Status: implemented 包内相对导入使用显式 `.ts` 说明符。 -`pnpm run build` 是两阶段构建: +`pnpm run build` 按 Host lib、Client lib 和 Web 排序;每个 lib 阶段都保持 tsc 先发射、tsdown 后打包: -- 阶段 1:在根 solution 上执行 `tsc -b`,将逐模块的 `.js`、声明文件 `.d.ts`、JS sourcemap `.js.map` 和声明 sourcemap `.d.ts.map` 输出到各包的 `lib/types`。这是权威的 TypeScript 编译结果。发布时保留 `.d.ts`;如果包的运行时 export 显式指向该输出树,也会保留其中的 `.js` 文件。`.js.map` 和 `.d.ts.map` 留在本地构建树中。 - - 该图是从根 solution `tsconfig.json` 经两个聚合可达的 project-reference 图([拓扑](2026-07-22-tsconfig-solution-root-two-aggregates.md)),用于校验并输出包/vendor 的构建结果。 -- 阶段 2:打包器读取 `lib/types` 下输出的 JS,将打包后的运行时入口写为 `lib/index.js` 或 `lib/index.mjs`(沿用当前行为)。此阶段仅做打包,禁止读取 TypeScript 源码或输出声明文件。 +- Host tsc 对 `tsconfig.host.json` 执行 `tsc -b`,把逐模块 `.js`、`.d.ts`、`.js.map` 与 `.d.ts.map` 输出到 Host 图各 package 的 `lib/types`;Host tsdown 随后读取这些 JS,生成发布入口并运行 Host TypeRT。 +- Client tsc 在 Host TypeRT 已生成 Remote Client 声明后对 `tsconfig.client.json` 执行 `tsc -b`;Client tsdown 再读取 Client 图发射的 JS,生成 Client package 的 Node loader 入口与 browser bundle。 +- Web build 只在两个 lib 阶段完成后启动。 `tsdown` 不再负责 TypeScript 编译或声明文件输出。 -`pnpm run typecheck` 运行同一张 `tsc -b` 图。 -- 两个聚合(`tsconfig.host.json`、`tsconfig.client.json`)以 `noEmit` 方式检查示例、测试和脚本,并通过 references 校验包/vendor 源码。 -- 被引用的包/vendor 项目保持与构建相同的输出行为,因此类型检查会刷新它们的 `lib/types` 输出,而无需使用独立的 no-emit 图。项目特定的严格度变更放在各自的 `packages/*/*/tsconfig.json` 或 `vendor/*/tsconfig.json` 中。 -- 两个 no-emit 聚合禁用 `rewriteRelativeImportExtensions`;它们不输出任何文件,且包含跨 project-reference 边界导入 helper 的测试。包/vendor 的 emit 项目保持重写开启。 +`pnpm run typecheck` 先执行 Host lib 阶段,以生成 Client 类型检查所需的 Remote 声明,再对 `tsconfig.client.json` 执行 `tsc -b`。两个 aggregate 本身以 `noEmit` 方式检查各自的示例、测试与脚本;被引用的 package/vendor project 保持与构建相同的发射行为。 复合项目将增量构建信息保存在各项目本地的 `lib/` 输出中。`pnpm run clean` 会根据根 TypeScript project-reference 图确定当前有效的输出目录,删除遗留的根目录构建信息,并删除已删除包留下且仅包含已知生成残留的 `packages/*/*` 目录。在删除现有目标前,该命令会解析目标父目录的真实路径;如果解析后的父目录位于仓库之外,则拒绝删除,防止使用符号链接的 project reference 将清理操作重定向到工作副本之外。对于仍有 `package.json` 的每个包,该命令都会保留 `node_modules`;如果不含 `package.json` 的目录中存在未知文件,则拒绝删除。构建不会自动调用 clean,因此常规构建会保留增量状态。 @@ -49,14 +46,18 @@ Status: implemented ```sh pnpm run build: -tsc -b -tsdown +tsc -b tsconfig.host.json +tsdown --env.DSH_BUILD_FACE host +tsc -b tsconfig.client.json +tsdown --env.DSH_BUILD_FACE client +pnpm run build:web pnpm run verify-node-next-types: tsx scripts/verify-node-next-types.ts pnpm run typecheck: -tsc -b +pnpm run build:lib:host +tsc -b tsconfig.client.json pnpm run clean: tsx scripts/clean.ts @@ -75,8 +76,8 @@ tsx scripts/clean.ts 构建职责更加清晰: -- `packages//` 和 `vendor/*` 下的每个模块有一份本地 tsconfig,同时服务于构建、类型检查和直接运行源码的工具(如 `dsh` 源码 loader、`tsx` 和 `vitest`)。 -- `build` 命令驱动根 solution 图。`tsc -b` 负责可发布的逐模块 `.js` 和 `.d.ts` 输出,打包器仅负责 `lib/index.*`。 +- `packages//` 和 `vendor/*` 下的每个普通模块有一份本地 tsconfig,同时服务于构建、类型检查和直接运行源码的工具(如 `dsh` 源码 loader、`tsx` 和 `vitest`)。`api/remotes` 因生成契约顺序使用一个 solution 和两个互斥的 emitting project,是唯一例外。 +- `build` 命令按 Host 与 Client Project Reference 图执行。每个阶段都由 `tsc -b` 负责可发布的逐模块 `.js` 和 `.d.ts` 输出,打包器仅负责发布 runtime bundle。 - `lib/types/*.d.ts` 是发布用的声明输出;`.d.ts.map` 只作为本地编译产物保留。 - `lib/types/*.d.ts` 使用显式 `.ts` 相对说明符,TypeScript 的 NodeNext/Node16 解析器会将其映射到同级的 `.d.ts` 文件。 - `lib/types/*.js` 通常仅作为打包器输入。只有显式运行时 export 指向该输出树时,才会发布这些文件。 diff --git a/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.i18n.yaml index d191386725..2a6cdcc2d0 100644 --- a/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.i18n.yaml @@ -1,6 +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 -2026-07-22-tsconfig-solution-root-two-aggregates.md: 19c229693b98ff3825caf935fa647ab85aff0f56 -2026-07-22-tsconfig-solution-root-two-aggregates.zh.md: becc43de1ef2f6a53b0f6c2285eb64d9b42604f1 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md +2026-07-22-tsconfig-solution-root-two-aggregates.md: 6e8d192b5fea1c7045ade6935fd0e2dc8434c502 +2026-07-22-tsconfig-solution-root-two-aggregates.zh.md: 60581e3de55227a393e2528ce7d3c8fa1bf9c570 diff --git a/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md b/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md index 19c229693b..6e8d192b5f 100644 --- a/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md +++ b/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md @@ -27,7 +27,7 @@ One solution root, two check units, one shared base pair, no separate build or v The load-bearing principle: **cordis `Context` declaration-merge collisions exist only inside a `ts.Program`, never in module resolution.** A solution file forms no program, so referencing both aggregates from one root cannot collide the merges; vite-tsconfig-paths reads only `paths` and `include` and discards types, so one facade may span both sides. The only way to explode is to flatten both sides into a single program — hence two derived disciplines: `tsconfig.base.json` never gains `include`/`files` (it would leak into every extending package and narrow the facade), and every repo-wide `ts.Program` consumer (`scripts/ts-project.ts`, doc-typecheck standalone mode) seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly, never the root solution. Program-backed generators and semantic gates intentionally stay host-only; the client side gets program-backed gates only when a real need arrives. -Commands collapse to one graph and keep the config name explicit: `typecheck` = `tsc -b tsconfig.json`, `build` = `tsc -b tsconfig.json && tsdown`, lefthook pre-push stays `tsc -b tsconfig.json --pretty false` unchanged (the same line now covers both sides through the solution). `tsconfig.build.json` and `tsconfig.vitest.json` are deleted; all vitest configs point vite-tsconfig-paths at `tsconfig.base.json`. +The root `tsconfig.json` remains the solution entry for explicitly running the complete Project Reference graph, and lefthook pre-push incrementally covers both sides through `tsc -b tsconfig.json --pretty false`. Because the Client depends on Remote contracts generated by Host tsdown, the repository's `build` and `typecheck` commands run the Host and Client in order; the [API Remotes build note](2026-08-08-api-remotes-generated-contract-build.md) owns the exact orchestration. `tsconfig.build.json` and `tsconfig.vitest.json` are deleted; all vitest configs point vite-tsconfig-paths at `tsconfig.base.json`. The solution root `extends` the base deliberately: `examples/` and `scripts/` have no nearer tsconfig, so tsx (get-tsconfig) resolves their workspace imports through the root file. `extends` restores the `paths` map there while `files: []` keeps the file program-less. Their *type checking* is unaffected by this: examples, scripts, and website files are included by the host aggregate. @@ -41,5 +41,5 @@ The solution root `extends` the base deliberately: `examples/` and `scripts/` ha - `docs/development.md#typescript-project-layout` is the authoritative description; root `AGENTS.md` carries the two disciplines as conventions. - The [ts-build-config note](2026-06-17-ts-build-config.md) keeps ownership of the tsc-first build pipeline (tsc emits, tsdown bundles, `.ts` specifiers with `rewriteRelativeImportExtensions`); its former "one root typecheck project" shape is superseded by this note. -- Adding a package registers it in exactly one aggregate's references (host packages in `tsconfig.host.json`, client packages in `tsconfig.client.json`); the build graph needs no separate registration. -- The build gate depends on the typecheck gate: both now drive the same `tsc -b` graph, so running them concurrently would race the same `.tsbuildinfo` files. +- Adding an ordinary package registers it in exactly one aggregate's references: Host packages in `tsconfig.host.json` and Client packages in `tsconfig.client.json`. `api/remotes` is the only explicit split exception because the Host generates a contract that the Client consumes later; its two concrete projects are registered separately, while its package-root solution enters neither aggregate. +- The Host and Client build phases must run serially: Client tsc cannot begin until Host tsdown has generated the contract. Each phase reuses its projects' incremental state instead of processing the same graph concurrently. diff --git a/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.zh.md b/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.zh.md index becc43de1e..60581e3de5 100644 --- a/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.zh.md @@ -27,7 +27,7 @@ GUI 拆分引入了第二个聚合 program(`tsconfig.client.json`,见[分层 整个方案立足的原则:**cordis `Context` 的声明合并冲突只存在于同一个 `ts.Program` 内部,从不发生在模块解析中。** solution 文件不构成 program,因此从一个根文件同时引用两个聚合不会让两侧的声明合并相撞;vite-tsconfig-paths 只读取 `paths` 与 `include`、丢弃全部类型信息,因此一个门面可以横跨两侧。唯一会爆炸的做法是把两侧压平进同一个 program,由此推出两条派生纪律:`tsconfig.base.json` 永远不得添加 `include`/`files`(否则会泄漏进每个继承它的包,并收窄门面范围);每个全仓级 `ts.Program` 消费方(`scripts/ts-project.ts`、doc-typecheck 独立模式)都显式以 `tsconfig.host.json` 或 `tsconfig.client.json` 为种子,绝不使用根 solution。基于 program 的生成器与语义门禁有意只留在宿主侧;客户端侧只有在真实需求出现时才引入基于 program 的门禁。 -各命令收敛到一张图,且显式写出配置名:`typecheck` = `tsc -b tsconfig.json`,`build` = `tsc -b tsconfig.json && tsdown`,lefthook pre-push 保持 `tsc -b tsconfig.json --pretty false` 不变(经由 solution,这同一行命令现已覆盖两侧)。`tsconfig.build.json` 与 `tsconfig.vitest.json` 删除;所有 vitest 配置都把 vite-tsconfig-paths 指向 `tsconfig.base.json`。 +根 `tsconfig.json` 仍是显式执行完整 Project Reference 图的 solution 入口,lefthook pre-push 通过 `tsc -b tsconfig.json --pretty false` 增量覆盖两侧。仓库的 `build` 与 `typecheck` 命令因 Client 依赖 Host tsdown 生成的 Remote 契约而按 Host、Client 顺序运行,具体编排由 [API Remotes 构建 Note](2026-08-08-api-remotes-generated-contract-build.md)负责。`tsconfig.build.json` 与 `tsconfig.vitest.json` 已删除;所有 vitest 配置都把 vite-tsconfig-paths 指向 `tsconfig.base.json`。 solution 根文件刻意 `extends` base:`examples/` 与 `scripts/` 没有更近的 tsconfig,tsx(get-tsconfig)通过根文件解析它们的 workspace 导入。`extends` 把 `paths` 映射带回根文件,`files: []` 则让它始终不构成 program。这不影响两者的*类型检查*:examples、scripts 与 website 的文件由宿主聚合纳入。 @@ -41,5 +41,5 @@ solution 根文件刻意 `extends` base:`examples/` 与 `scripts/` 没有更 - `docs/development.md#typescript-project-layout` 是权威描述;根 `AGENTS.md` 以约定形式收录上述两条纪律。 - [ts-build-config Agent Note](2026-06-17-ts-build-config.md) 继续拥有 tsc 先行的构建流水线(tsc 负责输出,tsdown 负责打包,`.ts` 说明符配合 `rewriteRelativeImportExtensions`);其原先「单一根类型检查项目」的形态由本文取代。 -- 新增一个包只登记进恰好一个聚合的 references(宿主包进 `tsconfig.host.json`,客户端包进 `tsconfig.client.json`);构建图无需另行登记。 -- 构建门禁依赖类型检查门禁:两者现在驱动同一张 `tsc -b` 图,并发运行会在同一批 `.tsbuildinfo` 文件上竞态。 +- 新增一个普通 package 只登记进恰好一个 aggregate 的 references(Host package 进 `tsconfig.host.json`,Client package 进 `tsconfig.client.json`)。`api/remotes` 因 Host 生成契约与 Client 消费契约的顺序关系成为唯一显式拆分例外;其两个具体 project 分别登记,包根 solution 不进入任一 aggregate。 +- Host 与 Client 构建阶段必须串行:Host tsdown 生成契约后 Client tsc 才能开始。各阶段复用各 project 的增量状态,不通过并发重复处理同一张图。 diff --git a/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.i18n.yaml new file mode 100644 index 0000000000..8b1bbf8b4d --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.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/process/2026-08-08-api-remotes-generated-contract-build.md +2026-08-08-api-remotes-generated-contract-build.md: ac9bb445917e11a4b57280da513d36b0f434bbaf +2026-08-08-api-remotes-generated-contract-build.zh.md: 4f9760078c209a22b9e03837fd81769e156b5df9 diff --git a/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md b/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md new file mode 100644 index 0000000000..ac9bb44591 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md @@ -0,0 +1,80 @@ +# Agent Note: Ordered Build for API Remotes Generated Contracts + +Status: implemented + +English | [中文](2026-08-08-api-remotes-generated-contract-build.zh.md) + +## Problem + +TypeRT must generate `/remote` declarations and runtime contributions from the Host's `@Remote` methods before the Client's `api-remotes/src/client/index.ts` can typecheck and bundle those contributions. If the root build hands both the Host and Client Project Reference graphs to tsc together, the Client compiles before the generated artifacts exist. Adding a separate contracts preprocessing step would instead compile the generator again outside the normal Host graph and let stale artifacts hide incorrect dependencies. + +This ordering dependency must not change the repository's ordinary package rule. A normal package belongs to exactly one TypeScript face: Host packages are registered in `tsconfig.host.json`, and Client packages in `tsconfig.client.json`. A Client plugin having both a Node loader entry and a browser entry describes its bundled artifact shapes, not a reason to split its TypeScript project. + +## Decision + +The root build completes Host tsc and Host tsdown first, with Host tsdown running TypeRT and generating the Remote Client contract. It then completes Client tsc, Client tsdown, and the Web build: + +~~~text +tsc -b tsconfig.host.json +tsdown --env.DSH_BUILD_FACE host +tsc -b tsconfig.client.json +tsdown --env.DSH_BUILD_FACE client +Vite Web build +~~~ + +`build:lib:host` owns the first two steps, `build:lib:client` owns the middle two, and `build:web` runs last. `typecheck` must also run the complete Host lib phase first because Client tsc requires declarations generated by Host tsdown; it does not need Client tsdown or the Web build. + +Each tsc phase is the sole TypeScript compiler path and emits JavaScript, declarations, and incremental state to `lib/types`. Tsdown reads only that JavaScript and produces published bundles; it neither reads source nor emits declarations. + +## The sole package exception + +`api/remotes` is the only package with both Host and Client composite projects. The Host project contains the Agent/Session lookup policy, Host plugin entry, and invariant; the Client project contains only `src/client/index.ts`, which must wait for the generated contract: + +~~~text +packages/api/remotes/ +├─ tsconfig.json +├─ tsconfig.host.json +├─ tsconfig.client.json +└─ src/ + ├─ index.ts + ├─ agent-lookup.ts + ├─ invariant.ts + └─ client/ + └─ index.ts +~~~ + +The package-root `tsconfig.json` is a solution that only references the two concrete projects; it enters neither aggregate nor any direct consumer's dependency graph. The root Host aggregate and `host/apiproxy` reference `api/remotes/tsconfig.host.json`, while the root Client aggregate and `client/ui-goal` reference `api/remotes/tsconfig.client.json`. `ui-goal` itself remains an ordinary single Client project. + +The two projects use disjoint `files` and separate `.tsbuildinfo` files, so they can share `lib/types` without emitting any source file twice. If both sides later need a shared implementation, move that implementation into a neutral package instead of giving the same source to two emitting projects. + +This exception follows from the real generated-contract ordering and is not a template available to ordinary packages. New packages remain restricted to one aggregate; adding another exception requires changing this decision and proving another generated dependency that cannot be eliminated. + +## TypeRT and tsdown + +Host tsdown enables `typertPlugin({ mode: 'workspace', faces: ['host'] })` in the normal root config. The generator uses only `tsconfig.host.json` as its program seed and produces both `typert.host.*` and the `typert.remote-client.*` projection of Host contracts; Client tsdown neither starts TypeRT nor analyzes the Client aggregate. + +The TypeRT analyzer distinguishes compiler faces from runtime faces. Direct Project References in the aggregate determine which compiler face analyzes a project; only a split project explicitly referenced through `tsconfig.host.json` or `tsconfig.client.json` is restricted to that corresponding face. Runtime models follow package subpath contributions instead, so an ordinary single-project `dshClient` package may contribute both Host and Client runtime models. + +Both the Host and Client tsdown passes receive the same complete workspace of `vendor/*`, `packages/*/*`, and `apps/cli`. The root config does not scan `lib/types/client/index.js`, maintain a package classification table, or use a tsdown filter; package-local configs return entries for the current phase according to `DSH_BUILD_FACE`. + +An ordinary Client plugin returns an empty config during the Host pass and produces both its Node loader entry and browser bundle during the Client pass. The `clientBundle(..., { hostPhase: true })` used by `api-remotes` is the only phase exception: the Host pass produces its Host entry, and the Client pass produces only its browser bundle. Package-local tsdown without `DSH_BUILD_FACE` still returns that package's normal entries together for local single-package development. + +## Alternatives considered + +**Keep a separate contracts preprocessing step.** This would compile the generator again outside the normal Host Project Reference graph and let residual generated artifacts hide the Client entering the Host graph too early. + +**Run the root `tsc -b tsconfig.json` once before tsdown.** Client tsc would run before Host tsdown and could not obtain `/remote` declarations from a clean worktree. + +**Split every package containing `src/client/index.ts`.** Separate Node and browser entries are the normal Client plugin bundling convention and do not create a compilation ordering dependency; splitting them universally would only increase the maintenance cost of references and incremental state. + +**Scan Client compilation artifacts or maintain two workspace lists.** Artifact scanning would make package participation depend on residual files, while hand-maintained lists and package-name filters would drift as directories change. A complete workspace with package-local face selection already provides deterministic behavior. + +**Run TypeRT again during the Client pass.** Remote Client is a projection of the Host contract and has no independent Client reflection source; a second TypeRT program would only duplicate work and increase the risk of mixing both sides' declarations into one analysis. + +## Consequences + +A clean build is the authoritative check of ordering correctness: with no existing `/remote` artifacts, Host tsc must succeed first, Host tsdown must generate the contract, and then Client tsc, Client tsdown, and the Web build must succeed. No phase may write artifacts into `src`. + +The tsc-first ownership established by the [TypeScript build config note](2026-06-17-ts-build-config.md) remains unchanged, but this note replaces its command shape of one whole-graph tsc pass followed by bundling with ordered phases. The ordinary-package single-aggregate rule established by the [two-aggregate solution note](2026-07-22-tsconfig-solution-root-two-aggregates.md) also remains unchanged; this note creates one explicit exception for `api/remotes`. + +An independent Client build is no longer a self-contained entry on a clean worktree; repository commands, CI, and release flows must run the Host lib phase first. Developers of ordinary packages do not need to understand or copy this exception and continue to choose one aggregate according to the package's runtime environment. diff --git a/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.zh.md b/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.zh.md new file mode 100644 index 0000000000..4f9760078c --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.zh.md @@ -0,0 +1,80 @@ +# Agent Note: API Remotes 生成契约的有序构建 + +Status: implemented + +[English](2026-08-08-api-remotes-generated-contract-build.md) | 中文 + +## 问题 + +Host 的 `@Remote` 方法需要先由 TypeRT 生成 `/remote` 声明和运行时贡献,Client 的 `api-remotes/src/client/index.ts` 才能通过类型检查并打包这些贡献。若根构建先把 Host 与 Client 两张 Project Reference 图一起交给 tsc,Client 会在生成产物存在之前编译;若增加独立 contracts 预处理,又会让 generator 脱离正常 Host 图重复编译,并允许陈旧产物掩盖错误依赖。 + +该顺序依赖不能改变仓库的普通 package 规则。正常 package 只属于一个 TypeScript face:Host package 登记在 `tsconfig.host.json`,Client package 登记在 `tsconfig.client.json`。一个 Client plugin 同时具有 Node loader 入口与 browser 入口,只是打包产物形态,不是拆分 TypeScript project 的理由。 + +## 决策 + +根构建先完成 Host tsc 和 Host tsdown,由 Host tsdown 运行 TypeRT 并生成 Remote Client 契约;随后完成 Client tsc、Client tsdown 和 Web 构建: + +~~~text +tsc -b tsconfig.host.json +tsdown --env.DSH_BUILD_FACE host +tsc -b tsconfig.client.json +tsdown --env.DSH_BUILD_FACE client +Vite Web build +~~~ + +`build:lib:host` 负责前两步,`build:lib:client` 负责中间两步,`build:web` 最后运行。`typecheck` 也必须先执行完整 Host lib 阶段,因为 Client tsc 需要 Host tsdown 生成的声明;它不需要运行 Client tsdown 或 Web build。 + +每个 tsc 阶段都是唯一的 TypeScript 编译器路径,负责向 `lib/types` 发射 JavaScript、声明和增量状态。tsdown 只读取这些 JavaScript 并生成发布 bundle,不读取源码,也不生成声明。 + +## 唯一的 package 特例 + +`api/remotes` 是唯一同时拥有 Host 与 Client composite project 的 package。Host project 包含 Agent/Session lookup 策略、Host 插件入口和 invariant;Client project 只包含需要等待生成契约的 `src/client/index.ts`: + +~~~text +packages/api/remotes/ +├─ tsconfig.json +├─ tsconfig.host.json +├─ tsconfig.client.json +└─ src/ + ├─ index.ts + ├─ agent-lookup.ts + ├─ invariant.ts + └─ client/ + └─ index.ts +~~~ + +包根 `tsconfig.json` 是只引用两个具体 project 的 solution,不进入任何 aggregate 或直接消费方的依赖图。根 Host aggregate 与 `host/apiproxy` 引用 `api/remotes/tsconfig.host.json`;根 Client aggregate 与 `client/ui-goal` 引用 `api/remotes/tsconfig.client.json`。`ui-goal` 本身仍是普通的单一 Client project。 + +两个 project 使用互不重叠的 `files` 和不同的 `.tsbuildinfo`,因此可以共享 `lib/types` 而不重复发射任何源码。若未来需要两侧共用一份实现,应把实现移入中立 package,不能把同一源码同时交给两个 emitting project。 + +这个例外由生成契约的真实先后关系决定,不是可供普通 package 选择的模板。新增 package 仍只能登记进一个 aggregate;只有修改本决策并证明存在另一条不可消除的生成依赖,才能增加例外。 + +## TypeRT 与 tsdown + +Host tsdown 在普通根配置中启用 `typertPlugin({ mode: 'workspace', faces: ['host'] })`。generator 只以 `tsconfig.host.json` 为 program 种子,生成 `typert.host.*` 以及 Host 契约投影出的 `typert.remote-client.*`;Client tsdown 不启动 TypeRT,也不分析 Client aggregate。 + +TypeScript compiler face 与 TypeRT 运行时产物 face 是两层概念。普通 `dshClient` package 即使只有一个 compiler project,也可以按公开 subpath 同时贡献 Host 与 Client 运行时模型;aggregate 显式引用 `tsconfig.host.json` 或 `tsconfig.client.json` 时,analyzer 才把该 project 限定到对应 face。因此 `api-remotes` 的 Host 分析不会顺带注册其 Client 入口,普通双入口 package 的 Host 模型也不会丢失。 + +Host 与 Client 两次 tsdown 都接收 `vendor/*`、`packages/*/*` 和 `apps/cli` 这组完整 workspace。根配置不扫描 `lib/types/client/index.js`,不维护 package 分类表,也不使用 tsdown filter;包内配置根据 `DSH_BUILD_FACE` 返回本阶段入口。 + +普通 Client plugin 在 Host pass 返回空配置,在 Client pass 同时生成 Node loader 入口与 browser bundle。`api-remotes` 的 `clientBundle(..., { hostPhase: true })` 是唯一阶段例外:Host pass 生成其 Host 入口,Client pass 只生成 browser bundle。未指定 `DSH_BUILD_FACE` 的 package-local tsdown 仍同时返回该 package 的正常入口,供本地单包开发使用。 + +## 考虑过的替代方案 + +**保留独立 contracts 预处理。** 这会在正常 Host Project Reference 图之外额外编译 generator,并让残留生成物掩盖 Client 过早进入 Host 图的问题。 + +**一次执行根 `tsc -b tsconfig.json` 后再运行 tsdown。** Client tsc 在 Host tsdown 之前发生,无法从干净工作树获得 `/remote` 声明。 + +**拆分所有包含 `src/client/index.ts` 的 package。** Node 与 browser 双入口是普通 Client plugin 的打包约定,不形成编译顺序依赖;普遍拆分只会增加 references 和增量状态的维护成本。 + +**扫描 Client 编译产物或维护两份 workspace 清单。** 产物扫描会让 package 是否参与构建取决于残留文件,手工清单和 package 名过滤则会随目录调整产生漂移。完整 workspace 加包内 face 选择已经提供确定行为。 + +**在 Client pass 再运行 TypeRT。** Remote Client 是 Host 契约的投影,没有独立 Client 反射源;第二个 TypeRT program 只会重复工作并增加两侧声明混入同一分析的风险。 + +## 后果 + +干净构建成为顺序正确性的权威验证:没有任何既存 `/remote` 产物时,Host tsc 必须先成功,Host tsdown 必须生成契约,随后 Client tsc、Client tsdown 与 Web build 必须成功。任何阶段都不得把产物写进 `src`。 + +[TypeScript 构建配置 Note](2026-06-17-ts-build-config.md)确定的 tsc-first 职责保持不变,但其单次全图 tsc 后再打包的命令形态由本文的有序阶段取代。[双 aggregate solution Note](2026-07-22-tsconfig-solution-root-two-aggregates.md)确定的普通 package 单 aggregate 规则保持不变,本文只为 `api/remotes` 建立一个显式例外。 + +Client 的独立构建不再是干净工作树上的自足入口;仓库命令、CI 和发布流程必须先运行 Host lib 阶段。普通 package 的开发者无需理解或复制该例外,仍按所属运行环境选择一个 aggregate。 diff --git a/AGENTS.md b/AGENTS.md index c265d3cf32..d77f5c5b16 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -109,7 +109,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, - **Opaque cross-boundary ids are branded** (`Branded` from `dsh-brand`), never bare `string`. - **Trust TypeScript at typed same-process seams.** Do not add runtime validation, fallback behavior, or hostile-input tests solely for values the static interface requires; validate at parser/config, queued, model/tool JSON, durable/file, worker, process, and wire boundaries. - **Source plane vs artifact plane, never mixed.** Static gates and tests resolve workspace imports through tsconfig `paths` to `src` and pass on a clean tree; gates consuming built `lib/` declare that dependency ([layout](docs/development.md#typescript-project-layout)). -- **`ts.Program` consumers seed `tsconfig.host.json` or `tsconfig.client.json`, never the root solution** — one program holding both sides collides the cordis `Context` merges ([layout](docs/development.md#typescript-project-layout)). +- **Keep compiler faces explicit.** Each package uses one aggregate except `api/remotes`; repo-wide programs seed a face config, never the root solution ([layout](docs/development.md#typescript-project-layout)). - **An empty `catch` names what it swallows** and why nothing else can reach it; keep the `try` to one statement. - **Prefer symmetry for parallel values**; unexplained asymmetry usually signals a missed extraction. - **Tests describe behavior, not correctness.** Change obsolete behavior with its tests; explain why in the PR. diff --git a/docs/api-gateway.i18n.yaml b/docs/api-gateway.i18n.yaml index 074644ff3e..6bf3151311 100644 --- a/docs/api-gateway.i18n.yaml +++ b/docs/api-gateway.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/api-gateway.md -api-gateway.md: 33dfb30c9da25e46b660a3fa54ef37f587cbda08 -api-gateway.zh.md: 633eb10c0f2f065ecf27545813cc17d79f391865 +api-gateway.md: 7d5c5b7e46a66b2bf56ee1a1bbd57e7758a4c520 +api-gateway.zh.md: cbf62258b7bf4a1d2f657cf1fc08a8dbc0a1a939 diff --git a/docs/api-gateway.md b/docs/api-gateway.md index 33dfb30c9d..7d5c5b7e46 100644 --- a/docs/api-gateway.md +++ b/docs/api-gateway.md @@ -94,7 +94,11 @@ The API Gateway package owns the Host dispatcher and Client Remote endpoint as p ## Strict generation pipeline -The root build orders `build:lib:host`, `build:lib:client`, and `build:web`. The Host lib build first runs `build:lib:contracts`: it compiles the TypeRT generator, then starts a Host `ts.Program` through `tsdown.typert-host.config.ts` with `tsconfig.host.json` as its seed. The generator does not put the Host and Client aggregates in the same program, so it does not trigger conflicts between the two Cordis `Context` declaration merges. +The root build runs `build:lib:host`, `build:lib:client`, and `build:web` in order. The Host lib phase first runs `tsc -b tsconfig.host.json`, then `tsdown --env.DSH_BUILD_FACE host`; the normal Host Project Reference graph compiles the TypeRT generator, which runs during this tsdown pass with the Host aggregate as its only `ts.Program` seed. The Client lib phase then runs `tsc -b tsconfig.client.json` and `tsdown --env.DSH_BUILD_FACE client`, consuming the newly generated Remote Client declarations and runtime contributions without starting TypeRT again. + +Both tsdown passes receive the complete workspace and bundle only JavaScript emitted to `lib/types` by the corresponding tsc phase. The root config does not scan Client artifacts, classify package names, or pass a maintained filter to tsdown; package-local configs return entries for the current phase based on `DSH_BUILD_FACE`. An ordinary Client plugin produces both its Node loader entry and browser bundle during the Client phase. + +`api-remotes` is the only package with split TypeScript faces. Its Host project owns the Agent/Session lookup policy, while its Client project depends on `/remote` declarations generated for business packages during Host tsdown; root aggregates and direct consumers must reference `api/remotes/tsconfig.host.json` or `api/remotes/tsconfig.client.json` respectively. The package's `clientBundle(..., { hostPhase: true })` produces its Host entry during Host tsdown and leaves only the browser entry for Client tsdown. Every other package remains registered in one aggregate. Each contributing business package writes generated files to its own `lib/` directory, not to its source directory: @@ -149,13 +153,13 @@ pnpm run dev:web `dsh` starts the Host source through tsx, so the Host can use the SRC fallback; `dev:web` watches only Client plugins with a `dshClient` declaration and rewrites their `lib/client.js`. It does not analyze Host decorators or generate Remote Client DTS. -Changing only a Remote method's implementation body without changing its contract does not require regenerating the TypeRT files. After adding or removing a decorator or changing an export name, namespace, parameter, return value, lookup, Context, or cancellation signature, regenerate the strict contracts before the Client bundle consumes the new artifacts: +Changing only a Remote method's implementation body without changing its contract does not require regenerating the TypeRT files. After adding or removing a decorator or changing an export name, namespace, parameter, return value, lookup, Context, or cancellation signature, rerun the ordered lib build so the Host generates the strict contract before the Client compiles and bundles the new contribution: ```sh -pnpm run build:lib:contracts +pnpm run build:lib ``` -The running Client watcher consumes these generated files when it rebundles; without a watcher, run `pnpm run build:lib:client`. Recompiling only the frontend source cannot infer new types from Host decorators. `pnpm run typecheck` includes `build:lib:contracts` as a prerequisite, and CI and release builds also use the strict generation pipeline. +The running Client watcher consumes these generated files when it rebundles. If `pnpm run build:lib:host` has already refreshed the Host contract, `pnpm run build:lib:client` can complete the Client side; a clean worktree cannot skip the Host phase. Recompiling only the frontend source cannot infer new types from Host decorators. `pnpm run typecheck` runs the Host lib phase before Client tsc, and CI and release builds use the same order. ## Boundaries diff --git a/docs/api-gateway.zh.md b/docs/api-gateway.zh.md index 633eb10c0f..cbf62258b7 100644 --- a/docs/api-gateway.zh.md +++ b/docs/api-gateway.zh.md @@ -94,7 +94,11 @@ API Gateway 包同时拥有 Host dispatcher 与 Client Remote endpoint 两个对 ## 严格生成链路 -根构建按 `build:lib:host`、`build:lib:client`、`build:web` 排序。Host lib 构建首先运行 `build:lib:contracts`:它先编译 TypeRT generator,再通过 `tsdown.typert-host.config.ts` 以 `tsconfig.host.json` 为种子启动 Host `ts.Program`。生成器不会把 Host 与 Client 聚合放入同一个 program,因而不会触发两侧 Cordis `Context` 声明合并冲突。 +根构建依次执行 `build:lib:host`、`build:lib:client` 与 `build:web`。Host lib 阶段先运行 `tsc -b tsconfig.host.json`,再运行 `tsdown --env.DSH_BUILD_FACE host`;TypeRT generator 由正常 Host Project Reference 图编译,并在这次 tsdown 中以 Host aggregate 为唯一 `ts.Program` 种子运行。Client lib 阶段随后运行 `tsc -b tsconfig.client.json` 与 `tsdown --env.DSH_BUILD_FACE client`,使用刚生成的 Remote Client 声明和运行时贡献,但不再次启动 TypeRT。 + +两次 tsdown 都接收完整 workspace,且都只打包 `lib/types` 中由对应 tsc 阶段发射的 JavaScript。根配置不扫描 Client 产物、不按 package 名分类,也不向 tsdown 传维护式 filter;各包的本地配置根据 `DSH_BUILD_FACE` 返回当前阶段的入口。普通 Client plugin 在 Client 阶段一起生成 Node loader 入口与 browser bundle。 + +`api-remotes` 是唯一拆分 TypeScript face 的 package 特例。它的 Host project 负责 Agent/Session lookup 策略,Client project 则依赖业务包在 Host tsdown 中生成的 `/remote` 声明;根 aggregate 与直接消费方必须分别引用 `api/remotes/tsconfig.host.json` 或 `api/remotes/tsconfig.client.json`。包内 `clientBundle(..., { hostPhase: true })` 让 Host 入口在 Host tsdown 中生成,让 Client tsdown 只生成 browser 入口。其他 package 仍只登记在一个 aggregate 中。 每个贡献业务包把生成文件写入自己的 `lib/`,而不是源码目录: @@ -149,13 +153,13 @@ pnpm run dev:web `dsh` 通过 tsx 启动 Host 源码,所以 Host 可以使用 SRC 回退;`dev:web` 只监听带 `dshClient` 声明的 Client plugin 并重写其 `lib/client.js`,它不会分析 Host decorator,也不会生成 Remote Client DTS。 -只修改 Remote 方法实现体而不改变契约时,无需重新生成 TypeRT 文件。新增或删除 decorator、修改导出名、namespace、参数、返回值、lookup、Context 或取消签名时,先重新生成严格契约,再让 Client bundle 使用新的产物: +只修改 Remote 方法实现体而不改变契约时,无需重新生成 TypeRT 文件。新增或删除 decorator、修改导出名、namespace、参数、返回值、lookup、Context 或取消签名时,重新执行有序 lib 构建,让 Host 先生成严格契约,再让 Client 编译并打包新的贡献: ```sh -pnpm run build:lib:contracts +pnpm run build:lib ``` -运行中的 Client watcher 会在重新打包时消费这些生成文件;没有 watcher 时运行 `pnpm run build:lib:client`。仅重新编译前端源码不能从 Host decorator 推导新类型。`pnpm run typecheck` 自带 `build:lib:contracts` 前置步骤,CI 与发布构建也使用严格生成链路。 +运行中的 Client watcher 会在重新打包时消费这些生成文件。若已单独运行 `pnpm run build:lib:host` 刷新 Host 契约,也可再运行 `pnpm run build:lib:client` 完成 Client 侧;干净工作树不能跳过 Host 阶段。仅重新编译前端源码不能从 Host decorator 推导新类型。`pnpm run typecheck` 会执行 Host lib 阶段后再运行 Client tsc,CI 与发布构建也使用同一顺序。 ## 边界 diff --git a/docs/cookbook/adding-a-package.i18n.yaml b/docs/cookbook/adding-a-package.i18n.yaml index 85c1af757b..0c26b8be17 100644 --- a/docs/cookbook/adding-a-package.i18n.yaml +++ b/docs/cookbook/adding-a-package.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/cookbook/adding-a-package.md -adding-a-package.md: a45b222f6aed905a18ef9b480c989e6045029afe -adding-a-package.zh.md: af0e4d0779fa99ce43ebccba00c33eab16c4d362 +adding-a-package.md: 8ab603ea7b235bd2a582c9232afaca281a969448 +adding-a-package.zh.md: 8c0bc9dbd02b18a388f8e6ce91af10753c0210a2 diff --git a/docs/cookbook/adding-a-package.md b/docs/cookbook/adding-a-package.md index a45b222f6a..8ab603ea7b 100644 --- a/docs/cookbook/adding-a-package.md +++ b/docs/cookbook/adding-a-package.md @@ -31,7 +31,7 @@ In-package relative imports use explicit `.ts` specifiers in source (for example | File | Change | |---|---| | `tsconfig.base.json` | no edit for an existing group; for a new group, add a `./packages//*/src` candidate to the `@deepseek-ai/dsh-*` wildcard | -| `tsconfig.host.json` (host-side package) or `tsconfig.client.json` (client-side package) | add `{ "path": "./packages//" }` to `references` — exactly one aggregate, never both ([layout](../development.md#typescript-project-layout)) | +| `tsconfig.host.json` (Host package) or `tsconfig.client.json` (Client package) | add `{ "path": "./packages//" }` to `references` — an ordinary package belongs to exactly one aggregate, never both. `api/remotes` uses a repository-specific split because the Host generates a contract that the Client consumes in a later phase; new packages must not copy it ([layout](../development.md#typescript-project-layout)) | | `knip.json` | only if the package has entrypoints that repository discovery does not already cover | A `packages/client/*` package additionally extends `tsconfig.base.client.json` instead of `tsconfig.base.json`, and a client plugin package declares `dshClient` in package.json, exports `./client`, and calls the shared tsdown preset (`packages/client/tsdown.client.ts`) — see [packages/client/AGENTS.md](../../packages/client/AGENTS.md) for the client-side contract. diff --git a/docs/cookbook/adding-a-package.zh.md b/docs/cookbook/adding-a-package.zh.md index af0e4d0779..8c0bc9dbd0 100644 --- a/docs/cookbook/adding-a-package.zh.md +++ b/docs/cookbook/adding-a-package.zh.md @@ -31,7 +31,7 @@ package.json 不变式(由 `pnpm run constraints` / `scripts/check-workspace-c | 文件 | 变更 | |---|---| | `tsconfig.base.json` | 已有分组无需编辑;新分组需为 `@deepseek-ai/dsh-*` 通配符添加 `./packages//*/src` 候选路径 | -| `tsconfig.host.json`(host 侧包)或 `tsconfig.client.json`(client 侧包) | 在 `references` 中添加 `{ "path": "./packages//" }`——恰好一个聚合,绝不两个都加([布局](../development.md#typescript-project-layout)) | +| `tsconfig.host.json`(Host 包)或 `tsconfig.client.json`(Client 包) | 在 `references` 中添加 `{ "path": "./packages//" }`——普通包恰好属于一个 aggregate,绝不两个都加。`api/remotes` 因 Host 生成契约与 Client 消费契约之间存在顺序依赖而使用仓库专属拆分,新增包不得仿照([布局](../development.md#typescript-project-layout)) | | `knip.json` | 仅当包有仓库发现机制尚未覆盖的入口时需要 | `packages/client/*` 包改为 extends `tsconfig.base.client.json`(而非 `tsconfig.base.json`);client 插件包还需在 package.json 声明 `dshClient`、导出 `./client`、调用共享 tsdown preset(`packages/client/tsdown.client.ts`)——client 侧见 [packages/client/AGENTS.md](../../packages/client/AGENTS.md)。 diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index b66552b175..5a1024e657 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.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/development.md -development.md: b7ecab3536d739c105f11a640a07ea83a22f4398 -development.zh.md: 33ceba9f05c45c06acae7c83425a30c5e26ca433 +development.md: acf279d182ca580c6e372be6fbdca8f46afdc445 +development.zh.md: 927c72be2de78f9e7f67565b9524db85c5aa1669 diff --git a/docs/development.md b/docs/development.md index b7ecab3536..acf279d182 100644 --- a/docs/development.md +++ b/docs/development.md @@ -43,24 +43,39 @@ Setup is complete when `pnpm run typecheck` exits successfully. ### TypeScript project layout -The repository typecheck runs the whole-repo `tsc -b` graph: it emits every package/vendor `lib/types` and checks examples, tests, and scripts through two no-emit aggregates. - -The repository's TypeScript configuration has exactly three roles; every tsconfig file plays one of them. +The repository uses isolated Host and Client aggregates. An ordinary package is registered in exactly one aggregate: Host packages in `tsconfig.host.json` and Client packages in `tsconfig.client.json`. | File | Role | Forms a program? | |---|---|---| -| `tsconfig.json` | Solution root: `extends` base, `files: []`, references to the two aggregates. The whole-repo `tsc -b tsconfig.json` graph, the tsserver discovery entry, and — through the inherited `paths` — the resolution config for tsx running `examples/` and `scripts/` (their nearest tsconfig is this file). | No | -| `tsconfig.host.json` | Host aggregate: host-side packages (via references), examples, tests, scripts, website. Excludes `packages/client`. | Yes | -| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`. | Yes | +| `tsconfig.json` | Solution root: `extends` base, `files: []`, and references to the two aggregates. It is the tsserver discovery entry and the entry for explicitly running the complete Project Reference graph; through the inherited `paths`, it is also the resolution config for tsx running `examples/` and `scripts/`. | No | +| `tsconfig.host.json` | Host aggregate: Host packages, examples, tests, scripts, website, and the exceptional Host project of `api/remotes`. | Yes | +| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`, and the exceptional Client project of `api/remotes`. | Yes | | `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No | -| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the client aggregate and every `packages/client/*` package. | No | +| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the Client aggregate and every `packages/client/*` package. | No | -Host and client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Two disciplines follow: +Host and Client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Three disciplines follow: - `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope. -- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges. Program-backed generators and gates (`scripts/ts-project.ts` consumers, doc-typecheck standalone mode) are host-only by decision; the client side gains program-backed tooling only with a concrete need. +- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges. +- A new package is registered in exactly one aggregate. Having both a Node loader entry and a browser entry is not a reason to split a package; an ordinary Client plugin produces both runtime artifacts during the Client build phase. -Static analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Decision record: [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md); the tsc-first emit pipeline is the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md). +`api/remotes` is the repository's only package with split Host and Client tsconfigs. Its Host entry must participate in the Host TypeRT graph, while its Client entry imports `/remote` declarations that Host tsdown must generate first. The package-root `tsconfig.json` is therefore only a solution, and the two aggregates and direct consumers reference `tsconfig.host.json` or `tsconfig.client.json` respectively. Do not copy this structure to other packages; see the [`api-remotes` README](../packages/api/remotes/README.md) for the complete boundary. + +The root build follows the generated dependency order: + +```sh +tsc -b tsconfig.host.json +tsdown --env.DSH_BUILD_FACE host +tsc -b tsconfig.client.json +tsdown --env.DSH_BUILD_FACE client +pnpm run build:web +``` + +Both tsdown passes use the same complete workspace match. They neither scan build artifacts to discover Client packages nor maintain a Host/Client package filter list. Package-local tsdown configs select entries for the current phase through `DSH_BUILD_FACE`: an ordinary Client plugin produces both its Node loader and browser bundle during the Client phase; `api-remotes` uses `hostPhase: true` to produce its Host entry early and only its browser bundle during the Client phase. Tsdown consumes only the JavaScript emitted to `lib/types` by the preceding tsc phase. + +TypeRT runs only during Host tsdown, seeded by `tsconfig.host.json`. It analyzes Host types and generates both Host reflection artifacts and the Host-for-Client Remote projection; Client tsdown does not start TypeRT. Consequently, `pnpm run typecheck` runs the complete Host lib phase before Client tsc, while `pnpm run build` continues through Client tsdown and the Web build. The [API Remotes generated-contract build note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md) records this ordering decision. + +Static analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. See the [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md) for the two-aggregate topology and the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md) for tsc-first emit ownership. Business services declare callable methods on the Host with `@Remote` or `@RemoteScope`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `api-remotes` composition loads those contributions under `ctx.remote` and scoped `agentCtx.remote` namespaces. See [API Gateway](api-gateway.md) for the generated artifacts on both sides, their assembly relationships, the SRC development fallback, and the Web build order. diff --git a/docs/development.zh.md b/docs/development.zh.md index 33ceba9f05..927c72be2d 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -43,24 +43,39 @@ pnpm run typecheck ### TypeScript 项目布局 -仓库类型检查会执行全仓 `tsc -b` 图:它会发射每个 package/vendor 的 `lib/types`,并通过两个 no-emit 聚合检查示例、测试和脚本。 - -仓库的 TypeScript 配置只有三种角色;每个 tsconfig 文件恰好扮演其中一种。 +仓库使用相互隔离的 Host 与 Client aggregate。普通 package 只登记进其中一个 aggregate;Host 包进入 `tsconfig.host.json`,Client 包进入 `tsconfig.client.json`。 | 文件 | 角色 | 是否构成 program? | |---|---|---| -| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个聚合。全仓 `tsc -b tsconfig.json` 图、tsserver 发现入口,并经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置(它们最近的 tsconfig 就是此文件)。 | 否 | -| `tsconfig.host.json` | host 聚合:host 侧各包(经 references)、示例、测试、脚本、website。排除 `packages/client`。 | 是 | -| `tsconfig.client.json` | client 聚合:`packages/client/*` 各包及其测试、`apps/web`。 | 是 | +| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个 aggregate。它是 tsserver 发现入口,也是显式执行整张 Project Reference 图时的入口;经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置。 | 否 | +| `tsconfig.host.json` | Host aggregate:Host package、示例、测试、脚本和 website,以及 `api/remotes` 的 Host 特例 project。 | 是 | +| `tsconfig.client.json` | Client aggregate:`packages/client/*` package 及其测试、`apps/web`,以及 `api/remotes` 的 Client 特例 project。 | 是 | | `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 | -| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 client 聚合和每个 `packages/client/*` 包 extends。 | 否 | +| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 Client aggregate 和每个 `packages/client/*` package extends。 | 否 | -host 与 client 保持两个聚合 program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个聚合,一个 paths 门面也可以横跨两侧。由此推出两条纪律: +Host 与 Client 保持两个 aggregate program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个 aggregate,一个 paths 门面也可以横跨两侧。由此推出三条纪律: - `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。 -- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个聚合展平进一个 program 会撞上 `Context` 合并冲突。基于 program 的生成器与门禁(`scripts/ts-project.ts` 的消费者、doc-typecheck standalone 模式)按决策仅覆盖 host 侧;client 侧只在出现真实需求时再获得基于 program 的工具。 +- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个 aggregate 展平进一个 program 会撞上 `Context` 合并冲突。 +- 新 package 只登记进一个 aggregate。包同时具有 Node loader 入口和 browser 入口并不构成拆分理由;普通 Client plugin 的两份运行时产物都在 Client 构建阶段生成。 -静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。决策记录:[solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md);tsc-first 发射管线见 [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md)。 +`api/remotes` 是唯一拆分 Host/Client tsconfig 的仓库特例。它的 Host 入口必须进入 Host TypeRT 图,而 Client 入口导入 Host tsdown 才会生成的 `/remote` 声明,因此本包根 `tsconfig.json` 只作为 solution,两个 aggregate 和直接消费方分别引用 `tsconfig.host.json` 或 `tsconfig.client.json`。不要把该结构推广到其他包;完整边界见 [`api-remotes` README](../packages/api/remotes/README.md)。 + +根构建按生成依赖排序: + +```sh +tsc -b tsconfig.host.json +tsdown --env.DSH_BUILD_FACE host +tsc -b tsconfig.client.json +tsdown --env.DSH_BUILD_FACE client +pnpm run build:web +``` + +两次 tsdown 都使用同一组完整 workspace 匹配,不扫描构建产物来发现 Client package,也不维护 Host/Client package 过滤表。包内 tsdown 配置根据 `DSH_BUILD_FACE` 决定当前阶段的入口:普通 Client plugin 在 Client 阶段同时生成 Node loader 与 browser bundle;`api-remotes` 通过 `hostPhase: true` 提前生成 Host 入口,再在 Client 阶段只生成 browser bundle。tsdown 只消费 `lib/types` 中由前置 tsc 发射的 JavaScript。 + +TypeRT 只在 Host tsdown 中以 `tsconfig.host.json` 为种子运行。它分析 Host 类型并生成 Host 反射产物及 Host-for-Client Remote 投影;Client tsdown 不启动 TypeRT。`pnpm run typecheck` 因此先执行完整 Host lib 阶段,再运行 Client tsc;`pnpm run build` 继续执行 Client tsdown 和 Web 构建。该顺序的决策记录见 [API Remotes 生成契约构建 Note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md)。 + +静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。双 aggregate 拓扑见 [solution-root Note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md),tsc-first 发射职责见 [ts-build-config Note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md)。 业务 Service 在 Host 使用 `@Remote` 或 `@RemoteScope` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `api-remotes` 组合加载这些贡献并挂到 `ctx.remote` 与作用域 `agentCtx.remote` namespace。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.md)。 diff --git a/docs/module-graph.md b/docs/module-graph.md index d963273363..ab9fc97a71 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -328,6 +328,9 @@ flowchart TD pkg_client_hmr --> pkg_client_modules pkg_client_hmr --> pkg_host_webserver pkg_client_hmr --> pkg_invariants + pkg_client_runtime --> pkg_invariants + pkg_client_runtime --> pkg_type_meta + pkg_client_runtime --> pkg_typert_registry pkg_credentials --> pkg_brand pkg_credentials --> pkg_invariants pkg_frontend_static --> pkg_host_webserver @@ -376,6 +379,29 @@ flowchart TD pkg_api_gateway --> pkg_client_connection pkg_api_gateway --> pkg_invariants pkg_api_gateway --> pkg_typert_registry + pkg_client_locale --> pkg_client_runtime + pkg_client_locale --> pkg_client_ui_primitives + pkg_client_locale --> pkg_client_ui_slots + pkg_client_locale --> pkg_invariants + pkg_client_test_runtime --> pkg_client_runtime + pkg_client_test_runtime --> pkg_client_ui_slots + pkg_client_test_runtime --> pkg_client_web_react + pkg_client_test_runtime --> pkg_host_apiproxy + pkg_client_test_runtime --> pkg_invariants + pkg_client_ui_models --> pkg_client_connection + pkg_client_ui_models --> pkg_client_runtime + pkg_client_ui_models --> pkg_client_schema_form + pkg_client_ui_models --> pkg_client_ui_primitives + pkg_client_ui_models --> pkg_client_ui_slots + pkg_client_ui_models --> pkg_client_web_react + pkg_client_ui_models --> pkg_invariants + pkg_client_ui_settings --> pkg_client_runtime + pkg_client_ui_settings --> pkg_client_ui_primitives + pkg_client_ui_settings --> pkg_client_ui_slots + pkg_client_ui_settings --> pkg_invariants + pkg_client_ui_trajectory --> pkg_client_runtime + pkg_client_ui_trajectory --> pkg_client_ui_primitives + pkg_client_ui_trajectory --> pkg_invariants pkg_credentials_local --> pkg_atomic_write pkg_credentials_local --> pkg_credentials pkg_credentials_local --> pkg_environment @@ -426,6 +452,36 @@ flowchart TD pkg_app_boot --> pkg_invariants pkg_app_boot --> pkg_paths pkg_app_boot --> pkg_system_prompt + pkg_client_ui_question --> pkg_client_locale + pkg_client_ui_question --> pkg_invariants + pkg_client_ui_settings_general --> pkg_client_connection + pkg_client_ui_settings_general --> pkg_client_locale + pkg_client_ui_settings_general --> pkg_client_runtime + pkg_client_ui_settings_general --> pkg_client_ui_primitives + pkg_client_ui_settings_general --> pkg_client_ui_settings + pkg_client_ui_settings_general --> pkg_client_ui_slots + pkg_client_ui_settings_general --> pkg_client_web_react + pkg_client_ui_settings_general --> pkg_invariants + pkg_client_ui_sidebar --> pkg_client_locale + pkg_client_ui_sidebar --> pkg_client_runtime + pkg_client_ui_sidebar --> pkg_client_ui_primitives + pkg_client_ui_sidebar --> pkg_client_ui_slots + pkg_client_ui_sidebar --> pkg_invariants + pkg_client_ui_slash --> pkg_client_locale + pkg_client_ui_slash --> pkg_client_runtime + pkg_client_ui_slash --> pkg_client_ui_primitives + pkg_client_ui_slash --> pkg_client_ui_slots + pkg_client_ui_slash --> pkg_invariants + pkg_client_ui_theme --> pkg_client_locale + pkg_client_ui_theme --> pkg_client_runtime + pkg_client_ui_theme --> pkg_client_ui_primitives + pkg_client_ui_theme --> pkg_client_ui_slots + pkg_client_ui_theme --> pkg_invariants + pkg_client_ui_workspace --> pkg_client_locale + pkg_client_ui_workspace --> pkg_client_runtime + pkg_client_ui_workspace --> pkg_client_ui_primitives + pkg_client_ui_workspace --> pkg_client_ui_slots + pkg_client_ui_workspace --> pkg_invariants pkg_code_runtime_worker --> pkg_code_runtime pkg_code_runtime_worker --> pkg_invariants pkg_code_runtime_worker --> pkg_session @@ -521,6 +577,10 @@ flowchart TD pkg_headless --> pkg_host_webserver pkg_headless --> pkg_invariants pkg_headless --> pkg_session + pkg_client_ui_layout --> pkg_client_runtime + pkg_client_ui_layout --> pkg_client_ui_slots + pkg_client_ui_layout --> pkg_client_ui_theme + pkg_client_ui_layout --> pkg_invariants pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants pkg_time_context --> pkg_session @@ -528,6 +588,16 @@ flowchart TD pkg_tmux_context --> pkg_bash pkg_tmux_context --> pkg_invariants pkg_tmux_context --> pkg_session + pkg_host_directory_picker_browse --> pkg_client_locale + pkg_host_directory_picker_browse --> pkg_client_runtime + pkg_host_directory_picker_browse --> pkg_client_ui_primitives + pkg_host_directory_picker_browse --> pkg_client_ui_slots + pkg_host_directory_picker_browse --> pkg_client_ui_workspace + pkg_host_directory_picker_browse --> pkg_invariants + pkg_host_directory_picker_native --> pkg_client_runtime + pkg_host_directory_picker_native --> pkg_client_ui_slots + pkg_host_directory_picker_native --> pkg_client_ui_workspace + pkg_host_directory_picker_native --> pkg_invariants pkg_pty --> pkg_agent pkg_pty --> pkg_brand pkg_pty --> pkg_invariants @@ -625,9 +695,20 @@ flowchart TD pkg_api_remotes --> pkg_session pkg_api_remotes --> pkg_session_persistence pkg_api_remotes --> pkg_typert_registry + pkg_client_ui_conversation --> pkg_client_locale + pkg_client_ui_conversation --> pkg_client_runtime + pkg_client_ui_conversation --> pkg_client_ui_primitives + pkg_client_ui_conversation --> pkg_client_ui_slash + pkg_client_ui_conversation --> pkg_client_ui_slots + pkg_client_ui_conversation --> pkg_invariants + pkg_client_ui_conversation --> pkg_token_meter pkg_command_feedback --> pkg_commands pkg_command_feedback --> pkg_invariants pkg_command_feedback --> pkg_session + pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse + pkg_host_directory_picker_auto --> pkg_host_directory_picker_native + pkg_host_directory_picker_auto --> pkg_host_webserver + pkg_host_directory_picker_auto --> pkg_invariants pkg_pty_local --> pkg_agent pkg_pty_local --> pkg_invariants pkg_pty_local --> pkg_pty @@ -781,10 +862,35 @@ flowchart TD pkg_tool_ask_user --> pkg_invariants pkg_tool_ask_user --> pkg_tools pkg_tool_ask_user --> pkg_user_interaction - pkg_client_runtime --> pkg_api_remotes - pkg_client_runtime --> pkg_invariants - pkg_client_runtime --> pkg_type_meta - pkg_client_runtime --> pkg_typert_registry + pkg_client_ui_command --> pkg_client_connection + pkg_client_ui_command --> pkg_client_locale + pkg_client_ui_command --> pkg_client_runtime + pkg_client_ui_command --> pkg_client_ui_conversation + pkg_client_ui_command --> pkg_client_ui_primitives + pkg_client_ui_command --> pkg_client_ui_slash + pkg_client_ui_command --> pkg_client_ui_slots + pkg_client_ui_command --> pkg_invariants + pkg_client_ui_deliverables --> pkg_client_locale + pkg_client_ui_deliverables --> pkg_client_runtime + pkg_client_ui_deliverables --> pkg_client_ui_conversation + pkg_client_ui_deliverables --> pkg_client_ui_slots + pkg_client_ui_deliverables --> pkg_invariants + pkg_client_ui_goal --> pkg_api_remotes + pkg_client_ui_goal --> pkg_client_locale + pkg_client_ui_goal --> pkg_client_runtime + pkg_client_ui_goal --> pkg_client_ui_conversation + pkg_client_ui_goal --> pkg_client_ui_primitives + pkg_client_ui_goal --> pkg_client_ui_slots + pkg_client_ui_goal --> pkg_goal + pkg_client_ui_goal --> pkg_invariants + pkg_client_ui_skill --> pkg_client_connection + pkg_client_ui_skill --> pkg_client_locale + pkg_client_ui_skill --> pkg_client_runtime + pkg_client_ui_skill --> pkg_client_ui_conversation + pkg_client_ui_skill --> pkg_client_ui_primitives + pkg_client_ui_skill --> pkg_client_ui_slash + pkg_client_ui_skill --> pkg_client_ui_slots + pkg_client_ui_skill --> pkg_invariants pkg_session_reference --> pkg_agent pkg_session_reference --> pkg_compact pkg_session_reference --> pkg_invariants @@ -916,29 +1022,42 @@ flowchart TD pkg_web_app --> pkg_bash_env pkg_web_app --> pkg_invariants pkg_web_app --> pkg_system_prompt - pkg_client_locale --> pkg_client_runtime - pkg_client_locale --> pkg_client_ui_primitives - pkg_client_locale --> pkg_client_ui_slots - pkg_client_locale --> pkg_invariants - pkg_client_test_runtime --> pkg_client_runtime - pkg_client_test_runtime --> pkg_client_ui_slots - pkg_client_test_runtime --> pkg_client_web_react - pkg_client_test_runtime --> pkg_host_apiproxy - pkg_client_test_runtime --> pkg_invariants - pkg_client_ui_models --> pkg_client_connection - pkg_client_ui_models --> pkg_client_runtime - pkg_client_ui_models --> pkg_client_schema_form - pkg_client_ui_models --> pkg_client_ui_primitives - pkg_client_ui_models --> pkg_client_ui_slots - pkg_client_ui_models --> pkg_client_web_react - pkg_client_ui_models --> pkg_invariants - pkg_client_ui_settings --> pkg_client_runtime - pkg_client_ui_settings --> pkg_client_ui_primitives - pkg_client_ui_settings --> pkg_client_ui_slots - pkg_client_ui_settings --> pkg_invariants - pkg_client_ui_trajectory --> pkg_client_runtime - pkg_client_ui_trajectory --> pkg_client_ui_primitives - pkg_client_ui_trajectory --> pkg_invariants + pkg_client_ui_model --> pkg_client_connection + pkg_client_ui_model --> pkg_client_locale + pkg_client_ui_model --> pkg_client_runtime + pkg_client_ui_model --> pkg_client_ui_command + pkg_client_ui_model --> pkg_client_ui_conversation + pkg_client_ui_model --> pkg_client_ui_primitives + pkg_client_ui_model --> pkg_client_ui_slash + pkg_client_ui_model --> pkg_client_ui_slots + pkg_client_ui_model --> pkg_invariants + pkg_client_ui_permission --> pkg_client_connection + pkg_client_ui_permission --> pkg_client_locale + pkg_client_ui_permission --> pkg_client_runtime + pkg_client_ui_permission --> pkg_client_schema_form + pkg_client_ui_permission --> pkg_client_ui_command + pkg_client_ui_permission --> pkg_client_ui_primitives + pkg_client_ui_permission --> pkg_client_ui_slash + pkg_client_ui_permission --> pkg_client_ui_slots + pkg_client_ui_permission --> pkg_invariants + pkg_client_ui_permission --> pkg_permission + pkg_client_ui_plan --> pkg_client_connection + pkg_client_ui_plan --> pkg_client_locale + pkg_client_ui_plan --> pkg_client_runtime + pkg_client_ui_plan --> pkg_client_ui_conversation + pkg_client_ui_plan --> pkg_client_ui_primitives + pkg_client_ui_plan --> pkg_client_ui_slots + pkg_client_ui_plan --> pkg_invariants + pkg_client_ui_plan --> pkg_plan_mode + pkg_client_ui_subagent --> pkg_client_locale + pkg_client_ui_subagent --> pkg_client_runtime + pkg_client_ui_subagent --> pkg_client_ui_conversation + pkg_client_ui_subagent --> pkg_client_ui_primitives + pkg_client_ui_subagent --> pkg_client_ui_slash + pkg_client_ui_subagent --> pkg_client_ui_slots + pkg_client_ui_subagent --> pkg_invariants + pkg_client_ui_subagent --> pkg_subagent + pkg_client_ui_subagent --> pkg_token_meter pkg_sdk_protocol --> pkg_invariants pkg_sdk_protocol --> pkg_llm pkg_sdk_protocol --> pkg_session @@ -981,36 +1100,6 @@ flowchart TD pkg_jsonrpc --> pkg_sdk_protocol pkg_jsonrpc --> pkg_session pkg_jsonrpc --> pkg_subagent - pkg_client_ui_question --> pkg_client_locale - pkg_client_ui_question --> pkg_invariants - pkg_client_ui_settings_general --> pkg_client_connection - pkg_client_ui_settings_general --> pkg_client_locale - pkg_client_ui_settings_general --> pkg_client_runtime - pkg_client_ui_settings_general --> pkg_client_ui_primitives - pkg_client_ui_settings_general --> pkg_client_ui_settings - pkg_client_ui_settings_general --> pkg_client_ui_slots - pkg_client_ui_settings_general --> pkg_client_web_react - pkg_client_ui_settings_general --> pkg_invariants - pkg_client_ui_sidebar --> pkg_client_locale - pkg_client_ui_sidebar --> pkg_client_runtime - pkg_client_ui_sidebar --> pkg_client_ui_primitives - pkg_client_ui_sidebar --> pkg_client_ui_slots - pkg_client_ui_sidebar --> pkg_invariants - pkg_client_ui_slash --> pkg_client_locale - pkg_client_ui_slash --> pkg_client_runtime - pkg_client_ui_slash --> pkg_client_ui_primitives - pkg_client_ui_slash --> pkg_client_ui_slots - pkg_client_ui_slash --> pkg_invariants - pkg_client_ui_theme --> pkg_client_locale - pkg_client_ui_theme --> pkg_client_runtime - pkg_client_ui_theme --> pkg_client_ui_primitives - pkg_client_ui_theme --> pkg_client_ui_slots - pkg_client_ui_theme --> pkg_invariants - pkg_client_ui_workspace --> pkg_client_locale - pkg_client_ui_workspace --> pkg_client_runtime - pkg_client_ui_workspace --> pkg_client_ui_primitives - pkg_client_ui_workspace --> pkg_client_ui_slots - pkg_client_ui_workspace --> pkg_invariants pkg_agent_spine_demo --> pkg_agent pkg_agent_spine_demo --> pkg_agent_loop pkg_agent_spine_demo --> pkg_bash_env @@ -1044,17 +1133,6 @@ flowchart TD pkg_subagent_dsh_sdk --> pkg_session pkg_subagent_dsh_sdk --> pkg_subagent pkg_subagent_dsh_sdk --> pkg_subprocess - pkg_client_ui_conversation --> pkg_client_locale - pkg_client_ui_conversation --> pkg_client_runtime - pkg_client_ui_conversation --> pkg_client_ui_primitives - pkg_client_ui_conversation --> pkg_client_ui_slash - pkg_client_ui_conversation --> pkg_client_ui_slots - pkg_client_ui_conversation --> pkg_invariants - pkg_client_ui_conversation --> pkg_token_meter - pkg_client_ui_layout --> pkg_client_runtime - pkg_client_ui_layout --> pkg_client_ui_slots - pkg_client_ui_layout --> pkg_client_ui_theme - pkg_client_ui_layout --> pkg_invariants pkg_acp_demo --> pkg_acp pkg_acp_demo --> pkg_agent_spine_demo pkg_acp_demo --> pkg_app_boot @@ -1075,85 +1153,6 @@ flowchart TD pkg_cli_demo --> pkg_session_persistence_jsonl pkg_cli_demo --> pkg_tools pkg_cli_demo --> pkg_workspace_context - pkg_host_directory_picker_browse --> pkg_client_locale - pkg_host_directory_picker_browse --> pkg_client_runtime - pkg_host_directory_picker_browse --> pkg_client_ui_primitives - pkg_host_directory_picker_browse --> pkg_client_ui_slots - pkg_host_directory_picker_browse --> pkg_client_ui_workspace - pkg_host_directory_picker_browse --> pkg_invariants - pkg_host_directory_picker_native --> pkg_client_runtime - pkg_host_directory_picker_native --> pkg_client_ui_slots - pkg_host_directory_picker_native --> pkg_client_ui_workspace - pkg_host_directory_picker_native --> pkg_invariants - pkg_client_ui_command --> pkg_client_connection - pkg_client_ui_command --> pkg_client_locale - pkg_client_ui_command --> pkg_client_runtime - pkg_client_ui_command --> pkg_client_ui_conversation - pkg_client_ui_command --> pkg_client_ui_primitives - pkg_client_ui_command --> pkg_client_ui_slash - pkg_client_ui_command --> pkg_client_ui_slots - pkg_client_ui_command --> pkg_invariants - pkg_client_ui_deliverables --> pkg_client_locale - pkg_client_ui_deliverables --> pkg_client_runtime - pkg_client_ui_deliverables --> pkg_client_ui_conversation - pkg_client_ui_deliverables --> pkg_client_ui_slots - pkg_client_ui_deliverables --> pkg_invariants - pkg_client_ui_goal --> pkg_api_remotes - pkg_client_ui_goal --> pkg_client_locale - pkg_client_ui_goal --> pkg_client_runtime - pkg_client_ui_goal --> pkg_client_ui_conversation - pkg_client_ui_goal --> pkg_client_ui_primitives - pkg_client_ui_goal --> pkg_client_ui_slots - pkg_client_ui_goal --> pkg_goal - pkg_client_ui_goal --> pkg_invariants - pkg_client_ui_plan --> pkg_client_connection - pkg_client_ui_plan --> pkg_client_locale - pkg_client_ui_plan --> pkg_client_runtime - pkg_client_ui_plan --> pkg_client_ui_conversation - pkg_client_ui_plan --> pkg_client_ui_primitives - pkg_client_ui_plan --> pkg_client_ui_slots - pkg_client_ui_plan --> pkg_invariants - pkg_client_ui_plan --> pkg_plan_mode - pkg_client_ui_skill --> pkg_client_connection - pkg_client_ui_skill --> pkg_client_locale - pkg_client_ui_skill --> pkg_client_runtime - pkg_client_ui_skill --> pkg_client_ui_conversation - pkg_client_ui_skill --> pkg_client_ui_primitives - pkg_client_ui_skill --> pkg_client_ui_slash - pkg_client_ui_skill --> pkg_client_ui_slots - pkg_client_ui_skill --> pkg_invariants - pkg_client_ui_subagent --> pkg_client_locale - pkg_client_ui_subagent --> pkg_client_runtime - pkg_client_ui_subagent --> pkg_client_ui_conversation - pkg_client_ui_subagent --> pkg_client_ui_primitives - pkg_client_ui_subagent --> pkg_client_ui_slash - pkg_client_ui_subagent --> pkg_client_ui_slots - pkg_client_ui_subagent --> pkg_invariants - pkg_client_ui_subagent --> pkg_subagent - pkg_client_ui_subagent --> pkg_token_meter - pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse - pkg_host_directory_picker_auto --> pkg_host_directory_picker_native - pkg_host_directory_picker_auto --> pkg_host_webserver - pkg_host_directory_picker_auto --> pkg_invariants - pkg_client_ui_model --> pkg_client_connection - pkg_client_ui_model --> pkg_client_locale - pkg_client_ui_model --> pkg_client_runtime - pkg_client_ui_model --> pkg_client_ui_command - pkg_client_ui_model --> pkg_client_ui_conversation - pkg_client_ui_model --> pkg_client_ui_primitives - pkg_client_ui_model --> pkg_client_ui_slash - pkg_client_ui_model --> pkg_client_ui_slots - pkg_client_ui_model --> pkg_invariants - pkg_client_ui_permission --> pkg_client_connection - pkg_client_ui_permission --> pkg_client_locale - pkg_client_ui_permission --> pkg_client_runtime - pkg_client_ui_permission --> pkg_client_schema_form - pkg_client_ui_permission --> pkg_client_ui_command - pkg_client_ui_permission --> pkg_client_ui_primitives - pkg_client_ui_permission --> pkg_client_ui_slash - pkg_client_ui_permission --> pkg_client_ui_slots - pkg_client_ui_permission --> pkg_invariants - pkg_client_ui_permission --> pkg_permission ``` | Package | Group | Depends on | @@ -1191,6 +1190,7 @@ flowchart TD | [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout) | | [`client-connection`](../packages/client/connection) | `client` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | +| [`client-runtime`](../packages/client/runtime) | `client` | [`invariants`](../packages/support/invariants), [`type-meta`](../packages/typert/type-meta), [`typert-registry`](../packages/typert/registry) | | [`credentials`](../packages/credentials/credentials) | `credentials` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`frontend-static`](../packages/host/frontend-static) | `host` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) | @@ -1207,6 +1207,11 @@ flowchart TD | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`api-gateway`](../packages/api/gateway) | `api` | [`client-connection`](../packages/client/connection), [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) | +| [`client-locale`](../packages/client/locale) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) | +| [`client-ui-models`](../packages/client/ui-models) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | +| [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) | | [`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) | @@ -1221,6 +1226,12 @@ flowchart TD | [`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) | | [`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-question`](../packages/client/ui-question) | `client` | [`client-locale`](../packages/client/locale), [`invariants`](../packages/support/invariants) | +| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | +| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`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) | | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | @@ -1244,8 +1255,11 @@ flowchart TD | [`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) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | +| [`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) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | +| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | +| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`pty`](../packages/pty/pty) | `pty` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`scripts`](../packages/sdk/scripts) | `sdk` | [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants) | @@ -1266,7 +1280,9 @@ flowchart TD | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | | [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection), [`settings`](../packages/settings/settings), [`user-approval`](../packages/ui/user-approval) | | [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`typert-registry`](../packages/typert/registry) | +| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`token-meter`](../packages/llm/token-meter) | | [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | +| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | | [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | @@ -1292,7 +1308,10 @@ flowchart TD | [`session-title-first-message-llm`](../packages/session-title/session-title-first-message-llm) | `session-title` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`session-title-llm`](../packages/session-title/session-title-llm) | | [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | -| [`client-runtime`](../packages/client/runtime) | `client` | [`api-remotes`](../packages/api/remotes), [`invariants`](../packages/support/invariants), [`type-meta`](../packages/typert/type-meta), [`typert-registry`](../packages/typert/registry) | +| [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | +| [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | | [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | @@ -1314,11 +1333,10 @@ flowchart TD | [`repository-plugin`](../packages/cordis/repository-plugin) | `cordis` | [`invariants`](../packages/support/invariants), [`mcp-client`](../packages/mcp/mcp-client), [`paths`](../packages/util/paths), [`skill-local`](../packages/skill/skill-local) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`system-prompt`](../packages/core/system-prompt) | -| [`client-locale`](../packages/client/locale) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) | -| [`client-ui-models`](../packages/client/ui-models) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | -| [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) | +| [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-permission`](../packages/client/ui-permission) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-command`](../packages/client/ui-command), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`permission`](../packages/ui/permission) | +| [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) | +| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | | [`sdk-protocol`](../packages/sdk/sdk-protocol) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | @@ -1326,27 +1344,8 @@ flowchart TD | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | -| [`client-ui-question`](../packages/client/ui-question) | `client` | [`client-locale`](../packages/client/locale), [`invariants`](../packages/support/invariants) | -| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | -| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`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) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`bash-env`](../packages/bash/bash-env), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks-local`](../packages/tasks/tasks-local), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`sdk-client`](../packages/sdk/sdk-client) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/sdk-client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | -| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`token-meter`](../packages/llm/token-meter) | -| [`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) | | [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/acp/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`cli-demo`](../packages/examples/cli-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | -| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | -| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | -| [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | -| [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) | -| [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | -| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | -| [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-permission`](../packages/client/ui-permission) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-command`](../packages/client/ui-command), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`permission`](../packages/ui/permission) | diff --git a/packages/AGENTS.md b/packages/AGENTS.md index 0e8c62841e..58db577ee5 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -19,7 +19,7 @@ These package-specific rules supplement the repo-wide [conventions](../AGENTS.md Naming notes: -- **Package tsconfig shape:** extends `tsconfig.base.json` (client: `tsconfig.base.client.json`), `rootDir: src`, `outDir: lib/types`, a `references` entry per workspace dependency plus `support/invariants`; registered in exactly one aggregate — host packages in `tsconfig.host.json`, client in `tsconfig.client.json` ([layout](../docs/development.md#typescript-project-layout)). +- **Package tsconfig:** extends `tsconfig.base.json` (Client: `tsconfig.base.client.json`), uses `rootDir: src`, `outDir: lib/types`, and references each workspace dependency plus `support/invariants`; registers in exactly one aggregate. Only `api/remotes` splits for generated contracts; ordinary two-entry Client plugins do not ([layout](../docs/development.md#typescript-project-layout)). - `src/types.ts` contains only types — no runtime code. - Tests live at package level under `tests/`, not `src/__tests__/`. - A package's README and JSDoc are part of the change: altered behavior (config keys, defaults, error codes, wire fields) updates them in the same commit. `doc-sync` gates what it can; apply [dsh-prose-standard](../.agents/skills/dsh-prose-standard/SKILL.md) for complete, concise prose and verify accuracy against code. diff --git a/packages/api/remotes/README.i18n.yaml b/packages/api/remotes/README.i18n.yaml index 82947331c5..f8f7a6400b 100644 --- a/packages/api/remotes/README.i18n.yaml +++ b/packages/api/remotes/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/api/remotes/README.md -README.md: 7f6a2114d900413d972584c0f1c141b7f835ba36 -README.zh.md: cce263747d696570f362811556fa6f5c0be0a0f5 +README.md: 3d9de0955faefe37c95ff8bb792d57c4fa1f1a3a +README.zh.md: 7490d68781d3a7b0002b73fe06056ec86c144575 diff --git a/packages/api/remotes/README.md b/packages/api/remotes/README.md index 7f6a2114d9..3d9de0955f 100644 --- a/packages/api/remotes/README.md +++ b/packages/api/remotes/README.md @@ -10,6 +10,14 @@ The current Client assembly mounts only the Goal Remote contribution. Cordis eff This package contains no transport or Host service discovery logic. Its Client face can be reused by Web or a future TUI that provides the same React-free `ctx.remote` contract. +## Build boundary + +An ordinary repository package belongs to one TypeScript face: Host packages are registered in the root `tsconfig.host.json`, and Client packages in the root `tsconfig.client.json`. `api-remotes` is the only deliberate exception because its Host entry must participate in the Host TypeRT graph, while `src/client/index.ts` cannot compile until Host tsdown has generated the business packages' `/remote` declarations. + +This package's root `tsconfig.json` is only a solution that references `tsconfig.host.json` and `tsconfig.client.json`. The Host aggregate and direct Host consumers reference the former, while the Client aggregate and direct Client consumers reference the latter; the package-root solution must not enter either aggregate's dependency graph. The two projects own disjoint source files and `.tsbuildinfo` files but share the `lib/types` output directory. + +The package-local `clientBundle(..., { hostPhase: true })` makes Host tsdown bundle the Host entry and the later Client tsdown bundle only the browser entry. Ordinary Client plugins remain single Client projects and produce both their Node loader entry and browser bundle during Client tsdown; do not copy this package's split merely because a package has both `src/index.ts` and `src/client/index.ts`. + ## Model Experience None, as this BFF selects Remote application methods and identity policy but registers no model surface. diff --git a/packages/api/remotes/README.zh.md b/packages/api/remotes/README.zh.md index cce263747d..7490d68781 100644 --- a/packages/api/remotes/README.zh.md +++ b/packages/api/remotes/README.zh.md @@ -10,6 +10,14 @@ 本包不包含传输逻辑或 Host 服务发现逻辑。Web 或未来的 TUI 只要提供同一份不依赖 React 的 `ctx.remote` 契约,均可复用其 Client face。 +## 构建边界 + +仓库中的普通包只属于一个 TypeScript face:Host 包登记在根 `tsconfig.host.json`,Client 包登记在根 `tsconfig.client.json`。`api-remotes` 是唯一刻意拆分的特例,因为它的 Host 入口要参与 Host TypeRT 图,而 `src/client/index.ts` 必须等 Host tsdown 生成业务包的 `/remote` 声明后才能编译。 + +本包根 `tsconfig.json` 只是引用 `tsconfig.host.json` 与 `tsconfig.client.json` 的 solution。Host aggregate 和 Host 直接消费方引用前者,Client aggregate 和 Client 直接消费方引用后者;禁止把包根 solution 放进任一 aggregate 的依赖图。两个 project 拥有互不重叠的源码和 `.tsbuildinfo`,但共享 `lib/types` 输出目录。 + +包内 `clientBundle(..., { hostPhase: true })` 让 Host tsdown 打包 Host 入口,让后续 Client tsdown 只打包 browser 入口。普通 Client 插件仍使用单一 Client project,并在 Client tsdown 阶段一起生成 Node loader 入口和 browser bundle;不得因一个包同时存在 `src/index.ts` 与 `src/client/index.ts` 就复制本包的拆分。 + ## 模型体验 无,因为该 BFF 只选择 Remote 应用方法和身份策略,不注册任何模型接口。 diff --git a/packages/typert/generator/README.i18n.yaml b/packages/typert/generator/README.i18n.yaml index cd6588c0ab..583835a63b 100644 --- a/packages/typert/generator/README.i18n.yaml +++ b/packages/typert/generator/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/typert/generator/README.md -README.md: c343fd9475a9407159037f0a10e3a0586a77c3da -README.zh.md: f9f863fe67d256b9744d7caeda0680f715808714 +README.md: 38030c2b7e07c70ab79001086640b6581943dbd9 +README.zh.md: afa45820b7c7fba77704b86902c8752cd45777e6 diff --git a/packages/typert/generator/README.md b/packages/typert/generator/README.md index c343fd9475..38030c2b7e 100644 --- a/packages/typert/generator/README.md +++ b/packages/typert/generator/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) TypeScript project analyzer and model-driven Typert generator. It converts the developer-authored source type tree into compiler-independent `FaceModel` and `TypeGraph` data before any artifact is rendered. Static analysis can consume that model without Cordis; emitters never receive TypeScript AST or checker objects. -Host and client use independent `ts.Program` instances seeded from `tsconfig.host.json` and `tsconfig.client.json`. Direct project references establish face membership, `package.json#exports` establishes every cross-package public boundary, and source imports or re-exports are the only allowed cross-face edges. Types owned by NPM dependencies, including global declarations from `@types` packages, remain `external` references instead of being expanded. +The analyzer can use independent `ts.Program` instances seeded from `tsconfig.host.json` or `tsconfig.client.json`. Direct project references establish compiler-face membership, while package subpaths establish TypeRT runtime-face contributions: an ordinary single-project `dshClient` package may contribute both Host and Client runtime models, and only a split project explicitly referenced through `tsconfig.host.json` or `tsconfig.client.json` is restricted to that corresponding face. `package.json#exports` establishes every cross-package public boundary, and source imports or re-exports are the only allowed cross-face edges. Types owned by NPM dependencies, including global declarations from `@types` packages, remain `external` references instead of being expanded. ## Analysis Model @@ -18,7 +18,7 @@ Each face contains package exports, Cordis services and events, explicitly tagge `WorkspaceTypertGenerator` discovers contributors by walking package public exports reachable from Cordis `Context` or `Events` augmentations and explicit `@typert` declarations. When invoked for artifact publication, it requires host artifacts at `lib/typert.host.{js,d.ts}` exposed as `package/typert`, and client artifacts at `lib/typert.client.{js,d.ts}` exposed as `package/client/typert`. Generated declarations expose `TYPERT` as `unknown`, so contributing business packages do not depend on the runtime registry. -Publication is package opt-in. The root build and typecheck do not generate Typert artifacts or require every business package to add Typert exports. Static consumers can call `WorkspaceAnalyzer` directly, select host/client and package subsets, and use bounded package batches without publishing or loading runtime artifacts. +Publication is package opt-in, and business packages without the corresponding public entry do not need Typert artifacts. The repository's Host tsdown runs workspace TypeRT generation with `tsconfig.host.json` as its only program seed; it produces both Host reflection artifacts and the `typert.remote-client.*` projection of Host Remote contracts for the Client. The subsequent Client tsdown neither starts TypeRT nor analyzes `tsconfig.client.json`. Static consumers can still call `WorkspaceAnalyzer` directly, explicitly select a face and package subset, and process packages in batches without publishing or loading runtime artifacts. ## Repository-specific Cordis projection diff --git a/packages/typert/generator/README.zh.md b/packages/typert/generator/README.zh.md index f9f863fe67..afa45820b7 100644 --- a/packages/typert/generator/README.zh.md +++ b/packages/typert/generator/README.zh.md @@ -4,7 +4,7 @@ TypeScript 项目分析器和模型驱动的 Typert 生成器。在生成任何产物之前,它会先将开发者编写的源类型树转换为独立于编译器的 `FaceModel` 和 `TypeGraph` 数据。静态分析无需 Cordis 即可消费该模型;各产物生成组件均不会接收 TypeScript 抽象语法树(AST)或类型检查器对象。 -宿主侧与客户端侧分别使用独立的 `ts.Program` 实例,二者以 `tsconfig.host.json` 和 `tsconfig.client.json` 初始化。直接项目引用确定各包所属的 face,`package.json#exports` 确定所有跨包公开边界,跨 face 的边则只能来自源码中的导入或重新导出。NPM 依赖拥有的类型(包括 `@types` 包中的全局声明)继续以 `external` 引用表示,不会被展开。 +分析器可以分别使用由 `tsconfig.host.json` 或 `tsconfig.client.json` 初始化的独立 `ts.Program`。直接 Project Reference 确定 compiler project 成员关系;带 `dshClient` 的普通单 project package 可按公开 subpath 同时贡献 Host 与 Client 运行时 face,显式引用 `tsconfig.host.json` 或 `tsconfig.client.json` 的拆分 project 则只贡献所选 face。`package.json#exports` 确定所有跨包公开边界,跨 face 的边只能来自源码导入或重新导出。NPM 依赖拥有的类型(包括 `@types` 包中的全局声明)继续以 `external` 引用表示,不会被展开。 ## 分析模型 @@ -18,7 +18,7 @@ TypeScript 项目分析器和模型驱动的 Typert 生成器。在生成任何 `WorkspaceTypertGenerator` 会遍历从 Cordis `Context` 或 `Events` 扩充声明及显式 `@typert` 声明可达的包公开导出,以发现贡献方。发布产物时,它要求宿主侧产物位于 `lib/typert.host.{js,d.ts}` 并以 `package/typert` 暴露,客户端侧产物位于 `lib/typert.client.{js,d.ts}` 并以 `package/client/typert` 暴露。生成的声明将 `TYPERT` 暴露为 `unknown`,因此参与贡献的业务包无需依赖运行时注册表。 -各包可自行选择是否发布。根目录的构建和类型检查不会生成 Typert 产物,也不要求每个业务包添加 Typert 导出。静态消费方可以直接调用 `WorkspaceAnalyzer`,选择宿主侧/客户端侧及包子集,并在不发布或加载运行时产物的情况下分批处理包,同时限制每批数量。 +各包可自行选择是否发布,未提供对应公开入口的业务包无需生成 Typert 产物。仓库的 Host tsdown 会以 `tsconfig.host.json` 为唯一 program 种子运行 workspace TypeRT 生成;它既生成 Host 反射产物,也把 Host Remote 契约投影为 Client 使用的 `typert.remote-client.*`。后续 Client tsdown 不启动 TypeRT,也不分析 `tsconfig.client.json`。静态消费方仍可直接调用 `WorkspaceAnalyzer`,显式选择 face 与包子集,并在不发布或加载运行时产物的情况下分批处理包。 ## 本仓库的 Cordis 投影 diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index 91509f3267..b2adbc38aa 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -16,11 +16,11 @@ }, { "role": "user", - "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThe setup tutorial takes a new contributor from prerequisites to a checked checkout. The contributor reference that follows covers repository layout, daily workflow, and CI shape. Design rationale and implementation details belong to the linked Agent Notes and scripts.\n\n## Setup tutorial\n\n### Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.\n- Optional: a DeepSeek API key for the Web, headless, and ACP automation demos and real-API e2e tests.\n\n### First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also configures worktree-local lefthook hooks through `scripts/install-lefthook.mjs`. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the safety and migration contract.\n\nIf hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\nIf the wrapper rejects existing Git configuration or reports a stale lock, follow its diagnostic and the linked Agent Note rather than editing worktree metadata speculatively. After moving a checkout, rerun the wrapper to regenerate the owned path.\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nSetup is complete when `pnpm run typecheck` exits successfully.\n\n## Contributor reference\n\n### TypeScript project layout\n\nThe repository typecheck runs the whole-repo `tsc -b` graph: it emits every package/vendor `lib/types` and checks examples, tests, and scripts through two no-emit aggregates.\n\nThe repository's TypeScript configuration has exactly three roles; every tsconfig file plays one of them.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `tsconfig.json` | Solution root: `extends` base, `files: []`, references to the two aggregates. The whole-repo `tsc -b tsconfig.json` graph, the tsserver discovery entry, and — through the inherited `paths` — the resolution config for tsx running `examples/` and `scripts/` (their nearest tsconfig is this file). | No |\n| `tsconfig.host.json` | Host aggregate: host-side packages (via references), examples, tests, scripts, website. Excludes `packages/client`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`. | Yes |\n| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |\n| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the client aggregate and every `packages/client/*` package. | No |\n\nHost and client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Two disciplines follow:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges. Program-backed generators and gates (`scripts/ts-project.ts` consumers, doc-typecheck standalone mode) are host-only by decision; the client side gains program-backed tooling only with a concrete need.\n\nStatic analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Decision record: [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md); the tsc-first emit pipeline is the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md).\n\nBusiness services declare callable methods on the Host with `@Remote` or `@RemoteScope`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `api-remotes` composition loads those contributions under `ctx.remote` and scoped `agentCtx.remote` namespaces. See [API Gateway](api-gateway.md) for the generated artifacts on both sides, their assembly relationships, the SRC development fallback, and the Web build order.\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n### Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n### Git hooks\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` applies formatting-only ESLint fixes, validates the staged files with Oxlint and applies its native fixes, regenerates `THIRD_PARTY_NOTICES.md` when a staged file is one of its inputs, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-push` runs only the incremental repository typecheck (`tsc -b` over the root solution, covering both the host and client aggregates).\n\nThe vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.\n\nThe hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of both Git hooks and is not an agent instruction.\n\n### CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n### Daily commands\n\nThe root [contributor instructions](../AGENTS.md#commands) summarize common commands, while [`package.json`](../package.json) and [scripts/run-gates.ts](../scripts/run-gates.ts) own the current script and gate inventories. Select the smallest checks that cover the changed surface. Documentation changes use `pnpm run doc-sync`; package-public behavior changes also update the owning README or JSDoc, and built-artifact checks require `pnpm run build` first.\n\n### Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\nThe self-referential cordis demo can inspect and modify its live plugin runtime and needs the same credentials (`web` by default, or `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n### Documenting types verbatim (`ts type-equiv`)\n\nThe [core data structures](core-data-structures/core.md) docs paste source-equivalent declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n" + "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThe setup tutorial takes a new contributor from prerequisites to a checked checkout. The contributor reference that follows covers repository layout, daily workflow, and CI shape. Design rationale and implementation details belong to the linked Agent Notes and scripts.\n\n## Setup tutorial\n\n### Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.\n- Optional: a DeepSeek API key for the Web, headless, and ACP automation demos and real-API e2e tests.\n\n### First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also configures worktree-local lefthook hooks through `scripts/install-lefthook.mjs`. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the safety and migration contract.\n\nIf hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\nIf the wrapper rejects existing Git configuration or reports a stale lock, follow its diagnostic and the linked Agent Note rather than editing worktree metadata speculatively. After moving a checkout, rerun the wrapper to regenerate the owned path.\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nSetup is complete when `pnpm run typecheck` exits successfully.\n\n## Contributor reference\n\n### TypeScript project layout\n\nThe repository uses isolated Host and Client aggregates. An ordinary package is registered in exactly one aggregate: Host packages in `tsconfig.host.json` and Client packages in `tsconfig.client.json`.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `tsconfig.json` | Solution root: `extends` base, `files: []`, and references to the two aggregates. It is the tsserver discovery entry and the entry for explicitly running the complete Project Reference graph; through the inherited `paths`, it is also the resolution config for tsx running `examples/` and `scripts/`. | No |\n| `tsconfig.host.json` | Host aggregate: Host packages, examples, tests, scripts, website, and the exceptional Host project of `api/remotes`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`, and the exceptional Client project of `api/remotes`. | Yes |\n| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |\n| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the Client aggregate and every `packages/client/*` package. | No |\n\nHost and Client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Three disciplines follow:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges.\n- A new package is registered in exactly one aggregate. Having both a Node loader entry and a browser entry is not a reason to split a package; an ordinary Client plugin produces both runtime artifacts during the Client build phase.\n\n`api/remotes` is the repository's only package with split Host and Client tsconfigs. Its Host entry must participate in the Host TypeRT graph, while its Client entry imports `/remote` declarations that Host tsdown must generate first. The package-root `tsconfig.json` is therefore only a solution, and the two aggregates and direct consumers reference `tsconfig.host.json` or `tsconfig.client.json` respectively. Do not copy this structure to other packages; see the [`api-remotes` README](../packages/api/remotes/README.md) for the complete boundary.\n\nThe root build follows the generated dependency order:\n\n```sh\ntsc -b tsconfig.host.json\ntsdown --env.DSH_BUILD_FACE host\ntsc -b tsconfig.client.json\ntsdown --env.DSH_BUILD_FACE client\npnpm run build:web\n```\n\nBoth tsdown passes use the same complete workspace match. They neither scan build artifacts to discover Client packages nor maintain a Host/Client package filter list. Package-local tsdown configs select entries for the current phase through `DSH_BUILD_FACE`: an ordinary Client plugin produces both its Node loader and browser bundle during the Client phase; `api-remotes` uses `hostPhase: true` to produce its Host entry early and only its browser bundle during the Client phase. Tsdown consumes only the JavaScript emitted to `lib/types` by the preceding tsc phase.\n\nTypeRT runs only during Host tsdown, seeded by `tsconfig.host.json`. It analyzes Host types and generates both Host reflection artifacts and the Host-for-Client Remote projection; Client tsdown does not start TypeRT. Consequently, `pnpm run typecheck` runs the complete Host lib phase before Client tsc, while `pnpm run build` continues through Client tsdown and the Web build. The [API Remotes generated-contract build note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md) records this ordering decision.\n\nStatic analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. See the [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md) for the two-aggregate topology and the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md) for tsc-first emit ownership.\n\nBusiness services declare callable methods on the Host with `@Remote` or `@RemoteScope`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `api-remotes` composition loads those contributions under `ctx.remote` and scoped `agentCtx.remote` namespaces. See [API Gateway](api-gateway.md) for the generated artifacts on both sides, their assembly relationships, the SRC development fallback, and the Web build order.\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n### Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n### Git hooks\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` applies formatting-only ESLint fixes, validates the staged files with Oxlint and applies its native fixes, regenerates `THIRD_PARTY_NOTICES.md` when a staged file is one of its inputs, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-push` runs only the incremental repository typecheck (`tsc -b` over the root solution, covering both the host and client aggregates).\n\nThe vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.\n\nThe hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of both Git hooks and is not an agent instruction.\n\n### CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n### Daily commands\n\nThe root [contributor instructions](../AGENTS.md#commands) summarize common commands, while [`package.json`](../package.json) and [scripts/run-gates.ts](../scripts/run-gates.ts) own the current script and gate inventories. Select the smallest checks that cover the changed surface. Documentation changes use `pnpm run doc-sync`; package-public behavior changes also update the owning README or JSDoc, and built-artifact checks require `pnpm run build` first.\n\n### Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\nThe self-referential cordis demo can inspect and modify its live plugin runtime and needs the same credentials (`web` by default, or `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n### Documenting types verbatim (`ts type-equiv`)\n\nThe [core data structures](core-data-structures/core.md) docs paste source-equivalent declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n" }, { "role": "assistant", - "content": "# 开发指南\n\n[English](development.md) | 中文\n\n搭建教程引导新贡献者从准备前置条件开始,直到检出通过检查。后面的贡献者参考介绍仓库布局、日常工作流和 CI 形态。设计依据与实现细节属于链接的 Agent Note 和脚本。\n\n## 搭建教程\n\n### 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。\n- 可选:一个 DeepSeek API key,用于 Web、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n### 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程还会通过 `scripts/install-lefthook.mjs` 配置 worktree 本地的 lefthook 钩子。其安全与迁移契约由 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 负责。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致缺少钩子,请手动安装:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\n如果包装脚本拒绝现有 Git 配置或报告陈旧锁,请遵循其诊断和所链接的 Agent Note,不要凭猜测编辑 worktree 元数据。移动检出目录后,请重新运行包装脚本以重新生成自有路径。\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n`pnpm run typecheck` 成功退出即表示搭建完成。\n\n## 贡献者参考\n\n### TypeScript 项目布局\n\n仓库类型检查会执行全仓 `tsc -b` 图:它会发射每个 package/vendor 的 `lib/types`,并通过两个 no-emit 聚合检查示例、测试和脚本。\n\n仓库的 TypeScript 配置只有三种角色;每个 tsconfig 文件恰好扮演其中一种。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个聚合。全仓 `tsc -b tsconfig.json` 图、tsserver 发现入口,并经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置(它们最近的 tsconfig 就是此文件)。 | 否 |\n| `tsconfig.host.json` | host 聚合:host 侧各包(经 references)、示例、测试、脚本、website。排除 `packages/client`。 | 是 |\n| `tsconfig.client.json` | client 聚合:`packages/client/*` 各包及其测试、`apps/web`。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 client 聚合和每个 `packages/client/*` 包 extends。 | 否 |\n\nhost 与 client 保持两个聚合 program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个聚合,一个 paths 门面也可以横跨两侧。由此推出两条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个聚合展平进一个 program 会撞上 `Context` 合并冲突。基于 program 的生成器与门禁(`scripts/ts-project.ts` 的消费者、doc-typecheck standalone 模式)按决策仅覆盖 host 侧;client 侧只在出现真实需求时再获得基于 program 的工具。\n\n静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。决策记录:[solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md);tsc-first 发射管线见 [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md)。\n\n业务 Service 在 Host 使用 `@Remote` 或 `@RemoteScope` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `api-remotes` 组合加载这些贡献并挂到 `ctx.remote` 与作用域 `agentCtx.remote` namespace。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.md)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n### 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n### Git 钩子\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 应用仅用于格式化的 ESLint 修复,使用 Oxlint 验证暂存文件并应用其原生修复,在暂存文件属于 `THIRD_PARTY_NOTICES.md` 的输入时重新生成该文件,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-push` 只运行仓库增量类型检查(对根 solution 执行 `tsc -b`,覆盖 host 与 client 两个聚合)。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于两个 Git 钩子,也不是对 agent 的指令。\n\n### CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n### 日常命令\n\n根目录的[贡献者说明](../AGENTS.md#commands)概述常用命令,[`package.json`](../package.json) 与 [scripts/run-gates.ts](../scripts/run-gates.ts) 则负责当前脚本和门禁清单。请选择覆盖变更表面的最小检查集。文档变更使用 `pnpm run doc-sync`;package 公开行为变更还需更新所属 README 或 JSDoc,而基于构建产物的检查需要先运行 `pnpm run build`。\n\n### 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\n自指的 cordis 演示可以检查并修改其实时插件运行时,并需要相同的凭证(默认 `web`,也可用 `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n### 逐字记录类型(`ts type-equiv`)\n\n[核心数据结构](core-data-structures/core.md)文档会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码契约。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n" + "content": "# 开发指南\n\n[English](development.md) | 中文\n\n搭建教程引导新贡献者从准备前置条件开始,直到检出通过检查。后面的贡献者参考介绍仓库布局、日常工作流和 CI 形态。设计依据与实现细节属于链接的 Agent Note 和脚本。\n\n## 搭建教程\n\n### 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。\n- 可选:一个 DeepSeek API key,用于 Web、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n### 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程还会通过 `scripts/install-lefthook.mjs` 配置 worktree 本地的 lefthook 钩子。其安全与迁移契约由 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 负责。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致缺少钩子,请手动安装:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\n如果包装脚本拒绝现有 Git 配置或报告陈旧锁,请遵循其诊断和所链接的 Agent Note,不要凭猜测编辑 worktree 元数据。移动检出目录后,请重新运行包装脚本以重新生成自有路径。\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n`pnpm run typecheck` 成功退出即表示搭建完成。\n\n## 贡献者参考\n\n### TypeScript 项目布局\n\n仓库使用相互隔离的 Host 与 Client aggregate。普通 package 只登记进其中一个 aggregate;Host 包进入 `tsconfig.host.json`,Client 包进入 `tsconfig.client.json`。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个 aggregate。它是 tsserver 发现入口,也是显式执行整张 Project Reference 图时的入口;经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置。 | 否 |\n| `tsconfig.host.json` | Host aggregate:Host package、示例、测试、脚本和 website,以及 `api/remotes` 的 Host 特例 project。 | 是 |\n| `tsconfig.client.json` | Client aggregate:`packages/client/*` package 及其测试、`apps/web`,以及 `api/remotes` 的 Client 特例 project。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 Client aggregate 和每个 `packages/client/*` package extends。 | 否 |\n\nHost 与 Client 保持两个 aggregate program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个 aggregate,一个 paths 门面也可以横跨两侧。由此推出三条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个 aggregate 展平进一个 program 会撞上 `Context` 合并冲突。\n- 新 package 只登记进一个 aggregate。包同时具有 Node loader 入口和 browser 入口并不构成拆分理由;普通 Client plugin 的两份运行时产物都在 Client 构建阶段生成。\n\n`api/remotes` 是唯一拆分 Host/Client tsconfig 的仓库特例。它的 Host 入口必须进入 Host TypeRT 图,而 Client 入口导入 Host tsdown 才会生成的 `/remote` 声明,因此本包根 `tsconfig.json` 只作为 solution,两个 aggregate 和直接消费方分别引用 `tsconfig.host.json` 或 `tsconfig.client.json`。不要把该结构推广到其他包;完整边界见 [`api-remotes` README](../packages/api/remotes/README.md)。\n\n根构建按生成依赖排序:\n\n```sh\ntsc -b tsconfig.host.json\ntsdown --env.DSH_BUILD_FACE host\ntsc -b tsconfig.client.json\ntsdown --env.DSH_BUILD_FACE client\npnpm run build:web\n```\n\n两次 tsdown 都使用同一组完整 workspace 匹配,不扫描构建产物来发现 Client package,也不维护 Host/Client package 过滤表。包内 tsdown 配置根据 `DSH_BUILD_FACE` 决定当前阶段的入口:普通 Client plugin 在 Client 阶段同时生成 Node loader 与 browser bundle;`api-remotes` 通过 `hostPhase: true` 提前生成 Host 入口,再在 Client 阶段只生成 browser bundle。tsdown 只消费 `lib/types` 中由前置 tsc 发射的 JavaScript。\n\nTypeRT 只在 Host tsdown 中以 `tsconfig.host.json` 为种子运行。它分析 Host 类型并生成 Host 反射产物及 Host-for-Client Remote 投影;Client tsdown 不启动 TypeRT。`pnpm run typecheck` 因此先执行完整 Host lib 阶段,再运行 Client tsc;`pnpm run build` 继续执行 Client tsdown 和 Web 构建。该顺序的决策记录见 [API Remotes 生成契约构建 Note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md)。\n\n静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。双 aggregate 拓扑见 [solution-root Note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md),tsc-first 发射职责见 [ts-build-config Note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md)。\n\n业务 Service 在 Host 使用 `@Remote` 或 `@RemoteScope` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `api-remotes` 组合加载这些贡献并挂到 `ctx.remote` 与作用域 `agentCtx.remote` namespace。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.md)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n### 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n### Git 钩子\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 应用仅用于格式化的 ESLint 修复,使用 Oxlint 验证暂存文件并应用其原生修复,在暂存文件属于 `THIRD_PARTY_NOTICES.md` 的输入时重新生成该文件,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-push` 只运行仓库增量类型检查(对根 solution 执行 `tsc -b`,覆盖 host 与 client 两个聚合)。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于两个 Git 钩子,也不是对 agent 的指令。\n\n### CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n### 日常命令\n\n根目录的[贡献者说明](../AGENTS.md#commands)概述常用命令,[`package.json`](../package.json) 与 [scripts/run-gates.ts](../scripts/run-gates.ts) 则负责当前脚本和门禁清单。请选择覆盖变更表面的最小检查集。文档变更使用 `pnpm run doc-sync`;package 公开行为变更还需更新所属 README 或 JSDoc,而基于构建产物的检查需要先运行 `pnpm run build`。\n\n### 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\n自指的 cordis 演示可以检查并修改其实时插件运行时,并需要相同的凭证(默认 `web`,也可用 `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n### 逐字记录类型(`ts type-equiv`)\n\n[核心数据结构](core-data-structures/core.md)文档会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码契约。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n" }, { "role": "user", From 2a7c1175be1c63023d62904d315bbff9463e1cd0 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 8 Aug 2026 11:52:18 +0800 Subject: [PATCH 34/37] fix(docs): keep doc typecheck on Host sources --- docs/api-gateway.i18n.yaml | 4 ++-- docs/api-gateway.md | 2 +- docs/api-gateway.zh.md | 2 +- scripts/doc-typecheck.ts | 28 ++++++++++++---------------- 4 files changed, 16 insertions(+), 20 deletions(-) diff --git a/docs/api-gateway.i18n.yaml b/docs/api-gateway.i18n.yaml index 6bf3151311..360e4b32e4 100644 --- a/docs/api-gateway.i18n.yaml +++ b/docs/api-gateway.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/api-gateway.md -api-gateway.md: 7d5c5b7e46a66b2bf56ee1a1bbd57e7758a4c520 -api-gateway.zh.md: cbf62258b7bf4a1d2f657cf1fc08a8dbc0a1a939 +api-gateway.md: e8aafc173dced3c4ead07421d92401411565ece6 +api-gateway.zh.md: 92681b72ffc573cde834cece19568ec3f1d515ce diff --git a/docs/api-gateway.md b/docs/api-gateway.md index 7d5c5b7e46..e8aafc173d 100644 --- a/docs/api-gateway.md +++ b/docs/api-gateway.md @@ -57,7 +57,7 @@ Remote methods may return a value synchronously or return a Promise. For coopera The Client uses concrete functions on ordinary objects, not a JavaScript Proxy. Direct and scoped calls appear under `ctx.remote.` and `agentCtx.remote.`. Each namespace is a traced Cordis child Service registered as `remote.`; the Client assembly mounts contributions through `ctx.remote.$mount()`, and the namespace unloads after its last method is withdrawn. Dependency declarations belong to the actual caller: only a business package that reads `ctx.remote.` or `agentCtx.remote.` declares both `remote` and `remote.` in its own `inject`; assemblies that only mount contributions and higher-level runtimes that do not call that namespace do not declare the namespace dependency on the business package's behalf. When an `@Remote` method has exactly one lookup parameter and a same-named `TypeRTContextMap` uses the same wire identity, the generated scoped signature omits that identity parameter. `@RemoteScope` generates only the scoped invocation interface. -```ts +```ts ignore-check import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { AgentContext } from '@deepseek-ai/dsh-client-runtime/client' import type { Context } from 'cordis' diff --git a/docs/api-gateway.zh.md b/docs/api-gateway.zh.md index cbf62258b7..92681b72ff 100644 --- a/docs/api-gateway.zh.md +++ b/docs/api-gateway.zh.md @@ -57,7 +57,7 @@ Remote 方法可以同步返回或返回 Promise。若需要协作式取消,Ho Client 使用普通对象上的具体函数,不使用 JavaScript Proxy。直接调用与作用域调用分别出现在 `ctx.remote.` 和 `agentCtx.remote.`。每个 namespace 都是注册为 `remote.` 的可追踪 Cordis 子 Service;Client assembly 通过 `ctx.remote.$mount()` 挂载贡献,最后一个方法撤回后该 namespace 随即卸载。依赖声明归实际调用方所有:只有读取 `ctx.remote.` 或 `agentCtx.remote.` 的业务包才在自己的 `inject` 中同时声明 `remote` 与 `remote.`;只负责挂载 contribution 的 assembly,以及不调用该 namespace 的上层 runtime,不代业务包声明 namespace 依赖。当一个 `@Remote` 方法恰好有一个 lookup 参数、且同名 `TypeRTContextMap` 使用相同 wire identity 时,生成的作用域签名会省略该 identity 参数。`@RemoteScope` 只生成作用域调用界面。 -```ts +```ts ignore-check import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { AgentContext } from '@deepseek-ai/dsh-client-runtime/client' import type { Context } from 'cordis' diff --git a/scripts/doc-typecheck.ts b/scripts/doc-typecheck.ts index 456dedecfe..efdb03eaad 100644 --- a/scripts/doc-typecheck.ts +++ b/scripts/doc-typecheck.ts @@ -136,25 +136,21 @@ function formatDiagnostics(diagnostics: readonly ts.Diagnostic[], blocks: Block[ } /** - * Reuse both aggregate reference sets from a temp project one directory below - * root. Each referenced package remains its own program, while documentation - * examples can import either the Host or Client API. + * Reuse the Host aggregate references from a temp project one directory below + * root. Generated Client API examples opt out because their declarations do + * not exist until Host tsdown has run. */ function workspaceReferences(): { path: string }[] { - const paths = new Set() - for (const aggregate of ['tsconfig.host.json', 'tsconfig.client.json']) { - const file = join(root, aggregate) - // Parse with TypeScript's own JSONC reader: a regex comment stripper corrupts the `/*/` path - // candidate in the workspace wildcard. - const result = ts.readConfigFile(file, path => readFileSync(path, 'utf8')) - if (result.error) { - throw new Error(`doc-typecheck: cannot read ${file}: ${ts.flattenDiagnosticMessageText(result.error.messageText, '\n')}`) - } - // `config` is typed `any` by the TS API; narrow it to the one field read here. - const { references } = result.config as { references: { path: string }[] } - for (const { path } of references) paths.add(path) + const file = join(root, 'tsconfig.host.json') + // Parse with TypeScript's own JSONC reader: a regex comment stripper corrupts the `/*/` path + // candidate in the workspace wildcard. + const result = ts.readConfigFile(file, path => readFileSync(path, 'utf8')) + if (result.error) { + throw new Error(`doc-typecheck: cannot read ${file}: ${ts.flattenDiagnosticMessageText(result.error.messageText, '\n')}`) } - return [...paths].map(path => ({ + // `config` is typed `any` by the TS API; narrow it to the one field read here. + const { references } = result.config as { references: { path: string }[] } + return references.map(({ path }) => ({ path: path.startsWith('./') ? `../${path.slice(2)}` : `../${path}`, })) } From d85d0806cddd8a28110b5a401453feb3477a984b Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 8 Aug 2026 12:02:58 +0800 Subject: [PATCH 35/37] fix(build): align Client build metadata --- ...08-api-remotes-generated-contract-build.i18n.yaml | 2 +- ...026-08-08-api-remotes-generated-contract-build.md | 2 +- apps/web/tests/assembled-boot.ts | 2 +- packages/client/tsdown.client.ts | 12 +++++++----- .../host/directory-picker-native/tsdown.config.ts | 2 +- scripts/run-gates.ts | 4 ++-- 6 files changed, 13 insertions(+), 11 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.i18n.yaml index 8b1bbf8b4d..5c1337a60c 100644 --- a/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.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/process/2026-08-08-api-remotes-generated-contract-build.md -2026-08-08-api-remotes-generated-contract-build.md: ac9bb445917e11a4b57280da513d36b0f434bbaf +2026-08-08-api-remotes-generated-contract-build.md: 83848290400441f0272b220ed0d396570e1ce2dc 2026-08-08-api-remotes-generated-contract-build.zh.md: 4f9760078c209a22b9e03837fd81769e156b5df9 diff --git a/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md b/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md index ac9bb44591..8384829040 100644 --- a/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md +++ b/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md @@ -53,7 +53,7 @@ This exception follows from the real generated-contract ordering and is not a te Host tsdown enables `typertPlugin({ mode: 'workspace', faces: ['host'] })` in the normal root config. The generator uses only `tsconfig.host.json` as its program seed and produces both `typert.host.*` and the `typert.remote-client.*` projection of Host contracts; Client tsdown neither starts TypeRT nor analyzes the Client aggregate. -The TypeRT analyzer distinguishes compiler faces from runtime faces. Direct Project References in the aggregate determine which compiler face analyzes a project; only a split project explicitly referenced through `tsconfig.host.json` or `tsconfig.client.json` is restricted to that corresponding face. Runtime models follow package subpath contributions instead, so an ordinary single-project `dshClient` package may contribute both Host and Client runtime models. +The TypeRT analyzer distinguishes compiler faces from runtime faces. Direct Project References in the aggregate determine which compiler face analyzes a project; only a split project explicitly referenced through `tsconfig.host.json` or `tsconfig.client.json` is restricted to that corresponding face. Runtime models follow package subpath contributions instead, so an ordinary single-project `dshClient` package may contribute both Host and Client runtime models. Consequently, Host analysis of `api-remotes` does not also register its Client entry, while an ordinary dual-entry package does not lose its Host model. Both the Host and Client tsdown passes receive the same complete workspace of `vendor/*`, `packages/*/*`, and `apps/cli`. The root config does not scan `lib/types/client/index.js`, maintain a package classification table, or use a tsdown filter; package-local configs return entries for the current phase according to `DSH_BUILD_FACE`. diff --git a/apps/web/tests/assembled-boot.ts b/apps/web/tests/assembled-boot.ts index 729428e47b..53f976af09 100644 --- a/apps/web/tests/assembled-boot.ts +++ b/apps/web/tests/assembled-boot.ts @@ -20,7 +20,7 @@ const PLUGINS: readonly (WebBootEntry & { bundlePath: string })[] = [ { id: '@deepseek-ai/dsh-client-connection', bundlePath: 'packages/client/connection/lib/client.js', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true }, { id: '@deepseek-ai/dsh-api-gateway', bundlePath: 'packages/api/gateway/lib/client.js', url: '/plugins/api-gateway.js', rev: 'fx', inject: ['@deepseek-ai/dsh-typert-registry', '@deepseek-ai/dsh-client-connection'], immediately: true }, { id: '@deepseek-ai/dsh-api-remotes', bundlePath: 'packages/api/remotes/lib/client.js', url: '/plugins/api-remotes.js', rev: 'fx', inject: ['@deepseek-ai/dsh-api-gateway'], immediately: true }, - { id: '@deepseek-ai/dsh-client-runtime', bundlePath: 'packages/client/runtime/lib/client.js', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection', '@deepseek-ai/dsh-api-remotes', '@deepseek-ai/dsh-typert-registry'], immediately: true }, + { id: '@deepseek-ai/dsh-client-runtime', bundlePath: 'packages/client/runtime/lib/client.js', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection', '@deepseek-ai/dsh-typert-registry'], immediately: true }, { id: '@deepseek-ai/dsh-client-ui-theme', bundlePath: 'packages/client/ui-theme/lib/client.js', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true }, { id: '@deepseek-ai/dsh-client-locale', bundlePath: 'packages/client/locale/lib/client.js', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true }, { id: '@deepseek-ai/dsh-client-ui-layout', bundlePath: 'packages/client/ui-layout/lib/client.js', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] }, diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index f2b7b7a3e6..2eebcc5308 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -93,10 +93,10 @@ export function clientBundle( const client = clientConfig(id, face === undefined ? 'src/client/index.ts' : 'lib/types/client/index.js') - const host = [lib, ...(options.host ?? [])] - if (face === 'host') return options.hostPhase === true ? host : [SKIP_WORKSPACE_BUILD] - if (face === 'client') return options.hostPhase === true ? [client] : [...host, client] - return [...host, client] + const node = [lib, ...(options.companions ?? [])] + if (face === 'host') return options.hostPhase === true ? node : [SKIP_WORKSPACE_BUILD] + if (face === 'client') return options.hostPhase === true ? [client] : [...node, client] + return [...node, client] } } @@ -125,7 +125,9 @@ export function clientOnly(configs: readonly UserConfig[]): BuildFaceConfig { interface ClientBundleOptions { /** Emit the Node-side artifacts during the Host pass instead of the Client pass. */ readonly hostPhase?: boolean - readonly host?: readonly UserConfig[] + /** Additional Node-side configs emitted alongside the package library. */ + readonly companions?: readonly UserConfig[] + /** Overrides for the package's primary Node-side library config. */ readonly lib?: UserConfig } diff --git a/packages/host/directory-picker-native/tsdown.config.ts b/packages/host/directory-picker-native/tsdown.config.ts index 4a4727a5aa..6d02727f4e 100644 --- a/packages/host/directory-picker-native/tsdown.config.ts +++ b/packages/host/directory-picker-native/tsdown.config.ts @@ -7,7 +7,7 @@ export default clientBundle( '@deepseek-ai/dsh-host-directory-picker-native', ['lib/types/index.js', 'lib/types/invariant.js'], { - host: [{ + companions: [{ // The artifact is lib/worker.cjs (the ./worker export the workspace // constraint keys on), bundled from the descriptive source entry. entry: { worker: 'lib/types/win32-dialog-worker.js' }, diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index c1c3e1699c..03064cb242 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -263,8 +263,8 @@ function ciPrimaryGates(): Gate[] { ...docSyncLeafGates(), pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }), pnpmScript('knip', 'knip'), - // typecheck and build now drive the same root solution graph; without the - // dependency two concurrent `tsc -b` runs race the same tsbuildinfo files. + // typecheck and build both drive the Host and Client tsc graphs; without + // the dependency concurrent runs race the same tsbuildinfo files. // The tsc step is an incremental no-op after typecheck. pnpmScript('build', 'build', { needs: ['typecheck'] }), pnpmScript('publint', 'publint', { needs: ['build'] }), From 863abcb42796cfab67e3fd722206905f9785251e Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 8 Aug 2026 12:25:41 +0800 Subject: [PATCH 36/37] build: enforce split project reference faces --- ...remotes-generated-contract-build.i18n.yaml | 4 +- ...08-api-remotes-generated-contract-build.md | 2 +- ...api-remotes-generated-contract-build.zh.md | 2 +- docs/development.i18n.yaml | 4 +- docs/development.md | 2 +- docs/development.zh.md | 2 +- scripts/check-workspace-constraints.ts | 2 + scripts/project-reference-faces.spec.ts | 100 ++++++++++++++ scripts/project-reference-faces.ts | 129 ++++++++++++++++++ 9 files changed, 239 insertions(+), 8 deletions(-) create mode 100644 scripts/project-reference-faces.spec.ts create mode 100644 scripts/project-reference-faces.ts diff --git a/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.i18n.yaml index 5c1337a60c..dec1b79d6a 100644 --- a/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.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/process/2026-08-08-api-remotes-generated-contract-build.md -2026-08-08-api-remotes-generated-contract-build.md: 83848290400441f0272b220ed0d396570e1ce2dc -2026-08-08-api-remotes-generated-contract-build.zh.md: 4f9760078c209a22b9e03837fd81769e156b5df9 +2026-08-08-api-remotes-generated-contract-build.md: 947465b19a7c399038ae8a3106f7563592365a8d +2026-08-08-api-remotes-generated-contract-build.zh.md: 7b559cd966c4acc055d41379c15f46fdfc649a00 diff --git a/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md b/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md index 8384829040..947465b19a 100644 --- a/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md +++ b/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md @@ -43,7 +43,7 @@ packages/api/remotes/ └─ index.ts ~~~ -The package-root `tsconfig.json` is a solution that only references the two concrete projects; it enters neither aggregate nor any direct consumer's dependency graph. The root Host aggregate and `host/apiproxy` reference `api/remotes/tsconfig.host.json`, while the root Client aggregate and `client/ui-goal` reference `api/remotes/tsconfig.client.json`. `ui-goal` itself remains an ordinary single Client project. +The package-root `tsconfig.json` is a solution that only references the two concrete projects; it enters neither aggregate nor any direct consumer's dependency graph. The root Host aggregate and `host/apiproxy` reference `api/remotes/tsconfig.host.json`, while the root Client aggregate and `client/ui-goal` reference `api/remotes/tsconfig.client.json`. `ui-goal` itself remains an ordinary single Client project. The workspace constraints gate walks the reachable Project Reference graph and rejects any face-declared project that references a split package's solution root or opposite leaf; targets with only `tsconfig.json` remain valid from either face. The two projects use disjoint `files` and separate `.tsbuildinfo` files, so they can share `lib/types` without emitting any source file twice. If both sides later need a shared implementation, move that implementation into a neutral package instead of giving the same source to two emitting projects. diff --git a/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.zh.md b/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.zh.md index 4f9760078c..7b559cd966 100644 --- a/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.zh.md @@ -43,7 +43,7 @@ packages/api/remotes/ └─ index.ts ~~~ -包根 `tsconfig.json` 是只引用两个具体 project 的 solution,不进入任何 aggregate 或直接消费方的依赖图。根 Host aggregate 与 `host/apiproxy` 引用 `api/remotes/tsconfig.host.json`;根 Client aggregate 与 `client/ui-goal` 引用 `api/remotes/tsconfig.client.json`。`ui-goal` 本身仍是普通的单一 Client project。 +包根 `tsconfig.json` 是只引用两个具体 project 的 solution,不进入任何 aggregate 或直接消费方的依赖图。根 Host aggregate 与 `host/apiproxy` 引用 `api/remotes/tsconfig.host.json`;根 Client aggregate 与 `client/ui-goal` 引用 `api/remotes/tsconfig.client.json`。`ui-goal` 本身仍是普通的单一 Client project。workspace constraints 门禁遍历可达的 Project Reference 图;凡已声明 face 的 project 引用了拆分包的 solution 根或另一侧 leaf,门禁都会拒绝,而只有 `tsconfig.json` 的目标仍可由任一 face 引用。 两个 project 使用互不重叠的 `files` 和不同的 `.tsbuildinfo`,因此可以共享 `lib/types` 而不重复发射任何源码。若未来需要两侧共用一份实现,应把实现移入中立 package,不能把同一源码同时交给两个 emitting project。 diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 5a1024e657..4ef3010835 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.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/development.md -development.md: acf279d182ca580c6e372be6fbdca8f46afdc445 -development.zh.md: 927c72be2de78f9e7f67565b9524db85c5aa1669 +development.md: 60a7ccc87e2c33e66b3d966a2907d31bb0b1efd8 +development.zh.md: 6607705be7e9f548b4f44555ad8c6cc8c2d34964 diff --git a/docs/development.md b/docs/development.md index acf279d182..60a7ccc87e 100644 --- a/docs/development.md +++ b/docs/development.md @@ -59,7 +59,7 @@ Host and Client stay two aggregate programs because both sides declaration-merge - A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges. - A new package is registered in exactly one aggregate. Having both a Node loader entry and a browser entry is not a reason to split a package; an ordinary Client plugin produces both runtime artifacts during the Client build phase. -`api/remotes` is the repository's only package with split Host and Client tsconfigs. Its Host entry must participate in the Host TypeRT graph, while its Client entry imports `/remote` declarations that Host tsdown must generate first. The package-root `tsconfig.json` is therefore only a solution, and the two aggregates and direct consumers reference `tsconfig.host.json` or `tsconfig.client.json` respectively. Do not copy this structure to other packages; see the [`api-remotes` README](../packages/api/remotes/README.md) for the complete boundary. +`api/remotes` is the repository's only package with split Host and Client tsconfigs. Its Host entry must participate in the Host TypeRT graph, while its Client entry imports `/remote` declarations that Host tsdown must generate first. The package-root `tsconfig.json` is therefore only a solution, and the two aggregates and direct consumers reference `tsconfig.host.json` or `tsconfig.client.json` respectively. The workspace `constraints` gate walks the reachable Project Reference graph and checks each referencing project's own compiler face: a single-config target remains valid from either face, while a split target must name the matching leaf rather than its solution root or opposite leaf. Do not copy this structure to other packages; see the [`api-remotes` README](../packages/api/remotes/README.md) for the complete boundary. The root build follows the generated dependency order: diff --git a/docs/development.zh.md b/docs/development.zh.md index 927c72be2d..6607705be7 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -59,7 +59,7 @@ Host 与 Client 保持两个 aggregate program,是因为两侧在相同键下 - 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个 aggregate 展平进一个 program 会撞上 `Context` 合并冲突。 - 新 package 只登记进一个 aggregate。包同时具有 Node loader 入口和 browser 入口并不构成拆分理由;普通 Client plugin 的两份运行时产物都在 Client 构建阶段生成。 -`api/remotes` 是唯一拆分 Host/Client tsconfig 的仓库特例。它的 Host 入口必须进入 Host TypeRT 图,而 Client 入口导入 Host tsdown 才会生成的 `/remote` 声明,因此本包根 `tsconfig.json` 只作为 solution,两个 aggregate 和直接消费方分别引用 `tsconfig.host.json` 或 `tsconfig.client.json`。不要把该结构推广到其他包;完整边界见 [`api-remotes` README](../packages/api/remotes/README.md)。 +`api/remotes` 是唯一拆分 Host/Client tsconfig 的仓库特例。它的 Host 入口必须进入 Host TypeRT 图,而 Client 入口导入 Host tsdown 才会生成的 `/remote` 声明,因此本包根 `tsconfig.json` 只作为 solution,两个 aggregate 和直接消费方分别引用 `tsconfig.host.json` 或 `tsconfig.client.json`。workspace `constraints` 门禁遍历可达的 Project Reference 图,并按各引用 project 自身的 compiler face 检查:只有单一配置的目标可由任一 face 引用,拆分配置的目标则必须引用匹配的 leaf,不得引用 solution 根或另一侧 leaf。不要把该结构推广到其他包;完整边界见 [`api-remotes` README](../packages/api/remotes/README.md)。 根构建按生成依赖排序: diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 9be97f53e6..05e3d5a9ce 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -8,6 +8,7 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs' import { join, relative, resolve } from 'node:path' import { hasTypeRTRemoteNavigation, isForbiddenPublicationFile } from './publication-payload.ts' +import { collectProjectReferenceFaceViolations } from './project-reference-faces.ts' const root = resolve(import.meta.dirname, '..') // vendor/* is single-level; packages// nests one level deeper @@ -305,6 +306,7 @@ const errors = [ ...checkRepositoryVersion(), ...workspaceManifests().flatMap(checkWorkspace), ...checkHierarchyShape(), + ...collectProjectReferenceFaceViolations(root), ] if (errors.length > 0) { console.error(errors.join('\n')) diff --git a/scripts/project-reference-faces.spec.ts b/scripts/project-reference-faces.spec.ts new file mode 100644 index 0000000000..93b3ab8c5f --- /dev/null +++ b/scripts/project-reference-faces.spec.ts @@ -0,0 +1,100 @@ +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 { collectProjectReferenceFaceViolations } from './project-reference-faces.ts' + +const roots: string[] = [] + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +function writeJson(path: string, value: unknown): void { + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`) +} + +function workspaceFixture(options: { + readonly host: readonly string[] + readonly client: readonly string[] +}): string { + const root = mkdtempSync(join(tmpdir(), 'dsh-project-reference-faces-')) + roots.push(root) + const shared = join(root, 'packages/core/shared') + const split = join(root, 'packages/api/split') + mkdirSync(shared, { recursive: true }) + mkdirSync(split, { recursive: true }) + writeJson(join(root, 'tsconfig.base.json'), {}) + writeJson(join(root, 'tsconfig.base.client.json'), { extends: './tsconfig.base.json' }) + writeJson(join(shared, 'package.json'), { name: '@deepseek-ai/dsh-shared' }) + writeJson(join(shared, 'tsconfig.json'), { + extends: '../../../tsconfig.base.json', + references: [], + }) + writeJson(join(split, 'package.json'), { name: '@deepseek-ai/dsh-split' }) + writeJson(join(split, 'tsconfig.json'), { + files: [], + references: [{ path: './tsconfig.host.json' }, { path: './tsconfig.client.json' }], + }) + writeJson(join(split, 'tsconfig.host.json'), { references: [{ path: '../../core/shared' }] }) + writeJson(join(split, 'tsconfig.client.json'), { references: [{ path: '../../core/shared' }] }) + writeJson(join(root, 'tsconfig.host.json'), { + references: options.host.map(path => ({ path })), + }) + writeJson(join(root, 'tsconfig.client.json'), { + references: options.client.map(path => ({ path })), + }) + return root +} + +describe('Project Reference compiler faces', () => { + it('allows neutral projects in either graph and matching split leaves', () => { + const root = workspaceFixture({ + host: ['./packages/core/shared', './packages/api/split/tsconfig.host.json'], + client: ['./packages/core/shared', './packages/api/split/tsconfig.client.json'], + }) + + expect(collectProjectReferenceFaceViolations(root)).toEqual([]) + }) + + it('rejects the opposite leaf and the solution root of a split project', () => { + const root = workspaceFixture({ + host: [ + './packages/api/split/tsconfig.host.json', + './packages/api/split/tsconfig.client.json', + ], + client: ['./packages/api/split'], + }) + + expect(collectProjectReferenceFaceViolations(root)).toEqual([ + 'tsconfig.client.json: Project Reference "./packages/api/split" enters split project packages/api/split from a Client config; reference "packages/api/split/tsconfig.client.json" instead', + 'tsconfig.host.json: Project Reference "./packages/api/split/tsconfig.client.json" enters split project packages/api/split from a Host config; reference "packages/api/split/tsconfig.host.json" instead', + ]) + }) + + it('uses the referencing project face throughout the reachable graph', () => { + const root = workspaceFixture({ + host: ['./packages/core/host-consumer'], + client: ['./packages/core/client-consumer'], + }) + const hostConsumer = join(root, 'packages/core/host-consumer') + mkdirSync(hostConsumer, { recursive: true }) + writeJson(join(hostConsumer, 'package.json'), { name: '@deepseek-ai/dsh-host-consumer' }) + writeJson(join(hostConsumer, 'tsconfig.json'), { + extends: '../../../tsconfig.base.json', + references: [{ path: '../../api/split/tsconfig.client.json' }], + }) + const clientConsumer = join(root, 'packages/core/client-consumer') + mkdirSync(clientConsumer, { recursive: true }) + writeJson(join(clientConsumer, 'package.json'), { name: '@deepseek-ai/dsh-client-consumer' }) + writeJson(join(clientConsumer, 'tsconfig.json'), { + extends: '../../../tsconfig.base.client.json', + references: [{ path: '../../api/split/tsconfig.host.json' }], + }) + + expect(collectProjectReferenceFaceViolations(root)).toEqual([ + 'packages/core/client-consumer/tsconfig.json: Project Reference "../../api/split/tsconfig.host.json" enters split project packages/api/split from a Client config; reference "packages/api/split/tsconfig.client.json" instead', + 'packages/core/host-consumer/tsconfig.json: Project Reference "../../api/split/tsconfig.client.json" enters split project packages/api/split from a Host config; reference "packages/api/split/tsconfig.host.json" instead', + ]) + }) +}) diff --git a/scripts/project-reference-faces.ts b/scripts/project-reference-faces.ts new file mode 100644 index 0000000000..0cff0dfa9e --- /dev/null +++ b/scripts/project-reference-faces.ts @@ -0,0 +1,129 @@ +/** Validate compiler-face isolation across workspace Project Reference graphs. */ + +import { existsSync, globSync } from 'node:fs' +import { basename, dirname, isAbsolute, relative, resolve, sep } from 'node:path' +import ts from 'typescript' + +type ProjectFace = 'host' | 'client' + +interface ProjectReferenceConfig { + readonly extends?: unknown + readonly references?: ReadonlyArray<{ readonly path?: unknown }> +} + +const WORKSPACE_MANIFESTS = [ + 'packages/*/*/package.json', + 'apps/*/package.json', + 'vendor/*/package.json', +] as const + +/** + * Find references that enter the wrong leaf of a split Host/Client project. + * + * A single-config project is neutral and may participate in either graph. Once + * a package declares both face configs, every reachable reference must name + * the leaf matching the aggregate from which traversal began. + * + * @param root - Repository root containing both aggregate tsconfigs. + * @returns Repo-relative diagnostics for every mismatched reference edge. + */ +export function collectProjectReferenceFaceViolations(root: string): string[] { + const splitRoots = splitProjectRoots(root) + const violations: string[] = [] + const pending = [resolve(root, 'tsconfig.host.json'), resolve(root, 'tsconfig.client.json')] + const visited = new Set() + for (let configPath = pending.pop(); configPath !== undefined; configPath = pending.pop()) { + if (visited.has(configPath) || !existsSync(configPath)) continue + visited.add(configPath) + const config = projectConfig(root, configPath) + const face = projectFace(root, configPath, config) + for (const reference of projectReferences(config)) { + const targetConfig = referenceConfigPath(configPath, reference) + const splitRoot = containingSplitRoot(splitRoots, targetConfig) + if (splitRoot !== undefined) { + if (face === undefined) { + violations.push( + `${repoPath(root, configPath)}: Project Reference ${JSON.stringify(reference)} enters split project ${repoPath(root, splitRoot)} from a config with no Host/Client face`, + ) + continue + } + const expected = resolve(splitRoot, `tsconfig.${face}.json`) + if (targetConfig !== expected) { + violations.push( + `${repoPath(root, configPath)}: Project Reference ${JSON.stringify(reference)} enters split project ${repoPath(root, splitRoot)} from a ${faceLabel(face)} config; reference ${JSON.stringify(repoPath(root, expected))} instead`, + ) + continue + } + } + pending.push(targetConfig) + } + } + + return violations.sort() +} + +function splitProjectRoots(root: string): string[] { + return globSync(WORKSPACE_MANIFESTS, { cwd: root }) + .map(manifest => resolve(root, dirname(manifest))) + .filter(dir => existsSync(resolve(dir, 'tsconfig.host.json')) + && existsSync(resolve(dir, 'tsconfig.client.json'))) + .sort((left, right) => right.length - left.length) +} + +function projectConfig(root: string, configPath: string): ProjectReferenceConfig { + const read = ts.readConfigFile(configPath, path => ts.sys.readFile(path)) + if (read.error !== undefined) { + const message = ts.flattenDiagnosticMessageText(read.error.messageText, '\n') + throw new Error(`${repoPath(root, configPath)}: ${message}`) + } + return read.config as ProjectReferenceConfig +} + +function projectReferences(config: ProjectReferenceConfig): string[] { + return (config.references ?? []) + .map(reference => reference.path) + .filter((path): path is string => typeof path === 'string') +} + +function projectFace( + root: string, + configPath: string, + config: ProjectReferenceConfig, + seen = new Set(), +): ProjectFace | undefined { + if (basename(configPath) === 'tsconfig.host.json') return 'host' + if (basename(configPath) === 'tsconfig.client.json') return 'client' + if (configPath === resolve(root, 'tsconfig.base.json')) return 'host' + if (configPath === resolve(root, 'tsconfig.base.client.json')) return 'client' + if (seen.has(configPath)) return undefined + seen.add(configPath) + const parent = localExtendsConfig(configPath, config.extends) + if (parent === undefined || !existsSync(parent)) return undefined + return projectFace(root, parent, projectConfig(root, parent), seen) +} + +function localExtendsConfig(configPath: string, value: unknown): string | undefined { + if (typeof value !== 'string' || !value.startsWith('.')) return undefined + const target = resolve(dirname(configPath), value) + return target.endsWith('.json') ? target : `${target}.json` +} + +function referenceConfigPath(sourceConfig: string, reference: string): string { + const target = resolve(dirname(sourceConfig), reference) + return target.endsWith('.json') ? target : resolve(target, 'tsconfig.json') +} + +function containingSplitRoot(splitRoots: readonly string[], targetConfig: string): string | undefined { + return splitRoots.find((root) => { + const path = relative(root, targetConfig) + return path !== '..' && !path.startsWith(`..${sep}`) && !isAbsolute(path) + }) +} + +function repoPath(root: string, path: string): string { + return relative(root, path).split(sep).join('/') +} + +function faceLabel(face: ProjectFace): string { + return face === 'host' ? 'Host' : 'Client' +} From 8fc9032d715c2840eae39c79c520c9e8a0d4c3ac Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 8 Aug 2026 12:36:50 +0800 Subject: [PATCH 37/37] test: refresh translation prompt snapshot --- .../translation-prompt-v4/request-response.expected.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index b2adbc38aa..0748e762dd 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -16,11 +16,11 @@ }, { "role": "user", - "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThe setup tutorial takes a new contributor from prerequisites to a checked checkout. The contributor reference that follows covers repository layout, daily workflow, and CI shape. Design rationale and implementation details belong to the linked Agent Notes and scripts.\n\n## Setup tutorial\n\n### Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.\n- Optional: a DeepSeek API key for the Web, headless, and ACP automation demos and real-API e2e tests.\n\n### First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also configures worktree-local lefthook hooks through `scripts/install-lefthook.mjs`. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the safety and migration contract.\n\nIf hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\nIf the wrapper rejects existing Git configuration or reports a stale lock, follow its diagnostic and the linked Agent Note rather than editing worktree metadata speculatively. After moving a checkout, rerun the wrapper to regenerate the owned path.\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nSetup is complete when `pnpm run typecheck` exits successfully.\n\n## Contributor reference\n\n### TypeScript project layout\n\nThe repository uses isolated Host and Client aggregates. An ordinary package is registered in exactly one aggregate: Host packages in `tsconfig.host.json` and Client packages in `tsconfig.client.json`.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `tsconfig.json` | Solution root: `extends` base, `files: []`, and references to the two aggregates. It is the tsserver discovery entry and the entry for explicitly running the complete Project Reference graph; through the inherited `paths`, it is also the resolution config for tsx running `examples/` and `scripts/`. | No |\n| `tsconfig.host.json` | Host aggregate: Host packages, examples, tests, scripts, website, and the exceptional Host project of `api/remotes`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`, and the exceptional Client project of `api/remotes`. | Yes |\n| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |\n| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the Client aggregate and every `packages/client/*` package. | No |\n\nHost and Client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Three disciplines follow:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges.\n- A new package is registered in exactly one aggregate. Having both a Node loader entry and a browser entry is not a reason to split a package; an ordinary Client plugin produces both runtime artifacts during the Client build phase.\n\n`api/remotes` is the repository's only package with split Host and Client tsconfigs. Its Host entry must participate in the Host TypeRT graph, while its Client entry imports `/remote` declarations that Host tsdown must generate first. The package-root `tsconfig.json` is therefore only a solution, and the two aggregates and direct consumers reference `tsconfig.host.json` or `tsconfig.client.json` respectively. Do not copy this structure to other packages; see the [`api-remotes` README](../packages/api/remotes/README.md) for the complete boundary.\n\nThe root build follows the generated dependency order:\n\n```sh\ntsc -b tsconfig.host.json\ntsdown --env.DSH_BUILD_FACE host\ntsc -b tsconfig.client.json\ntsdown --env.DSH_BUILD_FACE client\npnpm run build:web\n```\n\nBoth tsdown passes use the same complete workspace match. They neither scan build artifacts to discover Client packages nor maintain a Host/Client package filter list. Package-local tsdown configs select entries for the current phase through `DSH_BUILD_FACE`: an ordinary Client plugin produces both its Node loader and browser bundle during the Client phase; `api-remotes` uses `hostPhase: true` to produce its Host entry early and only its browser bundle during the Client phase. Tsdown consumes only the JavaScript emitted to `lib/types` by the preceding tsc phase.\n\nTypeRT runs only during Host tsdown, seeded by `tsconfig.host.json`. It analyzes Host types and generates both Host reflection artifacts and the Host-for-Client Remote projection; Client tsdown does not start TypeRT. Consequently, `pnpm run typecheck` runs the complete Host lib phase before Client tsc, while `pnpm run build` continues through Client tsdown and the Web build. The [API Remotes generated-contract build note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md) records this ordering decision.\n\nStatic analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. See the [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md) for the two-aggregate topology and the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md) for tsc-first emit ownership.\n\nBusiness services declare callable methods on the Host with `@Remote` or `@RemoteScope`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `api-remotes` composition loads those contributions under `ctx.remote` and scoped `agentCtx.remote` namespaces. See [API Gateway](api-gateway.md) for the generated artifacts on both sides, their assembly relationships, the SRC development fallback, and the Web build order.\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n### Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n### Git hooks\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` applies formatting-only ESLint fixes, validates the staged files with Oxlint and applies its native fixes, regenerates `THIRD_PARTY_NOTICES.md` when a staged file is one of its inputs, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-push` runs only the incremental repository typecheck (`tsc -b` over the root solution, covering both the host and client aggregates).\n\nThe vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.\n\nThe hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of both Git hooks and is not an agent instruction.\n\n### CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n### Daily commands\n\nThe root [contributor instructions](../AGENTS.md#commands) summarize common commands, while [`package.json`](../package.json) and [scripts/run-gates.ts](../scripts/run-gates.ts) own the current script and gate inventories. Select the smallest checks that cover the changed surface. Documentation changes use `pnpm run doc-sync`; package-public behavior changes also update the owning README or JSDoc, and built-artifact checks require `pnpm run build` first.\n\n### Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\nThe self-referential cordis demo can inspect and modify its live plugin runtime and needs the same credentials (`web` by default, or `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n### Documenting types verbatim (`ts type-equiv`)\n\nThe [core data structures](core-data-structures/core.md) docs paste source-equivalent declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n" + "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThe setup tutorial takes a new contributor from prerequisites to a checked checkout. The contributor reference that follows covers repository layout, daily workflow, and CI shape. Design rationale and implementation details belong to the linked Agent Notes and scripts.\n\n## Setup tutorial\n\n### Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.\n- Optional: a DeepSeek API key for the Web, headless, and ACP automation demos and real-API e2e tests.\n\n### First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also configures worktree-local lefthook hooks through `scripts/install-lefthook.mjs`. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the safety and migration contract.\n\nIf hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\nIf the wrapper rejects existing Git configuration or reports a stale lock, follow its diagnostic and the linked Agent Note rather than editing worktree metadata speculatively. After moving a checkout, rerun the wrapper to regenerate the owned path.\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nSetup is complete when `pnpm run typecheck` exits successfully.\n\n## Contributor reference\n\n### TypeScript project layout\n\nThe repository uses isolated Host and Client aggregates. An ordinary package is registered in exactly one aggregate: Host packages in `tsconfig.host.json` and Client packages in `tsconfig.client.json`.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `tsconfig.json` | Solution root: `extends` base, `files: []`, and references to the two aggregates. It is the tsserver discovery entry and the entry for explicitly running the complete Project Reference graph; through the inherited `paths`, it is also the resolution config for tsx running `examples/` and `scripts/`. | No |\n| `tsconfig.host.json` | Host aggregate: Host packages, examples, tests, scripts, website, and the exceptional Host project of `api/remotes`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`, and the exceptional Client project of `api/remotes`. | Yes |\n| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |\n| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the Client aggregate and every `packages/client/*` package. | No |\n\nHost and Client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Three disciplines follow:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges.\n- A new package is registered in exactly one aggregate. Having both a Node loader entry and a browser entry is not a reason to split a package; an ordinary Client plugin produces both runtime artifacts during the Client build phase.\n\n`api/remotes` is the repository's only package with split Host and Client tsconfigs. Its Host entry must participate in the Host TypeRT graph, while its Client entry imports `/remote` declarations that Host tsdown must generate first. The package-root `tsconfig.json` is therefore only a solution, and the two aggregates and direct consumers reference `tsconfig.host.json` or `tsconfig.client.json` respectively. The workspace `constraints` gate walks the reachable Project Reference graph and checks each referencing project's own compiler face: a single-config target remains valid from either face, while a split target must name the matching leaf rather than its solution root or opposite leaf. Do not copy this structure to other packages; see the [`api-remotes` README](../packages/api/remotes/README.md) for the complete boundary.\n\nThe root build follows the generated dependency order:\n\n```sh\ntsc -b tsconfig.host.json\ntsdown --env.DSH_BUILD_FACE host\ntsc -b tsconfig.client.json\ntsdown --env.DSH_BUILD_FACE client\npnpm run build:web\n```\n\nBoth tsdown passes use the same complete workspace match. They neither scan build artifacts to discover Client packages nor maintain a Host/Client package filter list. Package-local tsdown configs select entries for the current phase through `DSH_BUILD_FACE`: an ordinary Client plugin produces both its Node loader and browser bundle during the Client phase; `api-remotes` uses `hostPhase: true` to produce its Host entry early and only its browser bundle during the Client phase. Tsdown consumes only the JavaScript emitted to `lib/types` by the preceding tsc phase.\n\nTypeRT runs only during Host tsdown, seeded by `tsconfig.host.json`. It analyzes Host types and generates both Host reflection artifacts and the Host-for-Client Remote projection; Client tsdown does not start TypeRT. Consequently, `pnpm run typecheck` runs the complete Host lib phase before Client tsc, while `pnpm run build` continues through Client tsdown and the Web build. The [API Remotes generated-contract build note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md) records this ordering decision.\n\nStatic analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. See the [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md) for the two-aggregate topology and the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md) for tsc-first emit ownership.\n\nBusiness services declare callable methods on the Host with `@Remote` or `@RemoteScope`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `api-remotes` composition loads those contributions under `ctx.remote` and scoped `agentCtx.remote` namespaces. See [API Gateway](api-gateway.md) for the generated artifacts on both sides, their assembly relationships, the SRC development fallback, and the Web build order.\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n### Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n### Git hooks\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` applies formatting-only ESLint fixes, validates the staged files with Oxlint and applies its native fixes, regenerates `THIRD_PARTY_NOTICES.md` when a staged file is one of its inputs, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-push` runs only the incremental repository typecheck (`tsc -b` over the root solution, covering both the host and client aggregates).\n\nThe vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.\n\nThe hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of both Git hooks and is not an agent instruction.\n\n### CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n### Daily commands\n\nThe root [contributor instructions](../AGENTS.md#commands) summarize common commands, while [`package.json`](../package.json) and [scripts/run-gates.ts](../scripts/run-gates.ts) own the current script and gate inventories. Select the smallest checks that cover the changed surface. Documentation changes use `pnpm run doc-sync`; package-public behavior changes also update the owning README or JSDoc, and built-artifact checks require `pnpm run build` first.\n\n### Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\nThe self-referential cordis demo can inspect and modify its live plugin runtime and needs the same credentials (`web` by default, or `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n### Documenting types verbatim (`ts type-equiv`)\n\nThe [core data structures](core-data-structures/core.md) docs paste source-equivalent declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n" }, { "role": "assistant", - "content": "# 开发指南\n\n[English](development.md) | 中文\n\n搭建教程引导新贡献者从准备前置条件开始,直到检出通过检查。后面的贡献者参考介绍仓库布局、日常工作流和 CI 形态。设计依据与实现细节属于链接的 Agent Note 和脚本。\n\n## 搭建教程\n\n### 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。\n- 可选:一个 DeepSeek API key,用于 Web、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n### 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程还会通过 `scripts/install-lefthook.mjs` 配置 worktree 本地的 lefthook 钩子。其安全与迁移契约由 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 负责。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致缺少钩子,请手动安装:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\n如果包装脚本拒绝现有 Git 配置或报告陈旧锁,请遵循其诊断和所链接的 Agent Note,不要凭猜测编辑 worktree 元数据。移动检出目录后,请重新运行包装脚本以重新生成自有路径。\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n`pnpm run typecheck` 成功退出即表示搭建完成。\n\n## 贡献者参考\n\n### TypeScript 项目布局\n\n仓库使用相互隔离的 Host 与 Client aggregate。普通 package 只登记进其中一个 aggregate;Host 包进入 `tsconfig.host.json`,Client 包进入 `tsconfig.client.json`。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个 aggregate。它是 tsserver 发现入口,也是显式执行整张 Project Reference 图时的入口;经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置。 | 否 |\n| `tsconfig.host.json` | Host aggregate:Host package、示例、测试、脚本和 website,以及 `api/remotes` 的 Host 特例 project。 | 是 |\n| `tsconfig.client.json` | Client aggregate:`packages/client/*` package 及其测试、`apps/web`,以及 `api/remotes` 的 Client 特例 project。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 Client aggregate 和每个 `packages/client/*` package extends。 | 否 |\n\nHost 与 Client 保持两个 aggregate program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个 aggregate,一个 paths 门面也可以横跨两侧。由此推出三条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个 aggregate 展平进一个 program 会撞上 `Context` 合并冲突。\n- 新 package 只登记进一个 aggregate。包同时具有 Node loader 入口和 browser 入口并不构成拆分理由;普通 Client plugin 的两份运行时产物都在 Client 构建阶段生成。\n\n`api/remotes` 是唯一拆分 Host/Client tsconfig 的仓库特例。它的 Host 入口必须进入 Host TypeRT 图,而 Client 入口导入 Host tsdown 才会生成的 `/remote` 声明,因此本包根 `tsconfig.json` 只作为 solution,两个 aggregate 和直接消费方分别引用 `tsconfig.host.json` 或 `tsconfig.client.json`。不要把该结构推广到其他包;完整边界见 [`api-remotes` README](../packages/api/remotes/README.md)。\n\n根构建按生成依赖排序:\n\n```sh\ntsc -b tsconfig.host.json\ntsdown --env.DSH_BUILD_FACE host\ntsc -b tsconfig.client.json\ntsdown --env.DSH_BUILD_FACE client\npnpm run build:web\n```\n\n两次 tsdown 都使用同一组完整 workspace 匹配,不扫描构建产物来发现 Client package,也不维护 Host/Client package 过滤表。包内 tsdown 配置根据 `DSH_BUILD_FACE` 决定当前阶段的入口:普通 Client plugin 在 Client 阶段同时生成 Node loader 与 browser bundle;`api-remotes` 通过 `hostPhase: true` 提前生成 Host 入口,再在 Client 阶段只生成 browser bundle。tsdown 只消费 `lib/types` 中由前置 tsc 发射的 JavaScript。\n\nTypeRT 只在 Host tsdown 中以 `tsconfig.host.json` 为种子运行。它分析 Host 类型并生成 Host 反射产物及 Host-for-Client Remote 投影;Client tsdown 不启动 TypeRT。`pnpm run typecheck` 因此先执行完整 Host lib 阶段,再运行 Client tsc;`pnpm run build` 继续执行 Client tsdown 和 Web 构建。该顺序的决策记录见 [API Remotes 生成契约构建 Note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md)。\n\n静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。双 aggregate 拓扑见 [solution-root Note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md),tsc-first 发射职责见 [ts-build-config Note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md)。\n\n业务 Service 在 Host 使用 `@Remote` 或 `@RemoteScope` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `api-remotes` 组合加载这些贡献并挂到 `ctx.remote` 与作用域 `agentCtx.remote` namespace。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.md)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n### 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n### Git 钩子\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 应用仅用于格式化的 ESLint 修复,使用 Oxlint 验证暂存文件并应用其原生修复,在暂存文件属于 `THIRD_PARTY_NOTICES.md` 的输入时重新生成该文件,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-push` 只运行仓库增量类型检查(对根 solution 执行 `tsc -b`,覆盖 host 与 client 两个聚合)。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于两个 Git 钩子,也不是对 agent 的指令。\n\n### CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n### 日常命令\n\n根目录的[贡献者说明](../AGENTS.md#commands)概述常用命令,[`package.json`](../package.json) 与 [scripts/run-gates.ts](../scripts/run-gates.ts) 则负责当前脚本和门禁清单。请选择覆盖变更表面的最小检查集。文档变更使用 `pnpm run doc-sync`;package 公开行为变更还需更新所属 README 或 JSDoc,而基于构建产物的检查需要先运行 `pnpm run build`。\n\n### 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\n自指的 cordis 演示可以检查并修改其实时插件运行时,并需要相同的凭证(默认 `web`,也可用 `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n### 逐字记录类型(`ts type-equiv`)\n\n[核心数据结构](core-data-structures/core.md)文档会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码契约。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n" + "content": "# 开发指南\n\n[English](development.md) | 中文\n\n搭建教程引导新贡献者从准备前置条件开始,直到检出通过检查。后面的贡献者参考介绍仓库布局、日常工作流和 CI 形态。设计依据与实现细节属于链接的 Agent Note 和脚本。\n\n## 搭建教程\n\n### 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。\n- 可选:一个 DeepSeek API key,用于 Web、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n### 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程还会通过 `scripts/install-lefthook.mjs` 配置 worktree 本地的 lefthook 钩子。其安全与迁移契约由 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 负责。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致缺少钩子,请手动安装:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\n如果包装脚本拒绝现有 Git 配置或报告陈旧锁,请遵循其诊断和所链接的 Agent Note,不要凭猜测编辑 worktree 元数据。移动检出目录后,请重新运行包装脚本以重新生成自有路径。\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n`pnpm run typecheck` 成功退出即表示搭建完成。\n\n## 贡献者参考\n\n### TypeScript 项目布局\n\n仓库使用相互隔离的 Host 与 Client aggregate。普通 package 只登记进其中一个 aggregate;Host 包进入 `tsconfig.host.json`,Client 包进入 `tsconfig.client.json`。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个 aggregate。它是 tsserver 发现入口,也是显式执行整张 Project Reference 图时的入口;经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置。 | 否 |\n| `tsconfig.host.json` | Host aggregate:Host package、示例、测试、脚本和 website,以及 `api/remotes` 的 Host 特例 project。 | 是 |\n| `tsconfig.client.json` | Client aggregate:`packages/client/*` package 及其测试、`apps/web`,以及 `api/remotes` 的 Client 特例 project。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 Client aggregate 和每个 `packages/client/*` package extends。 | 否 |\n\nHost 与 Client 保持两个 aggregate program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个 aggregate,一个 paths 门面也可以横跨两侧。由此推出三条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个 aggregate 展平进一个 program 会撞上 `Context` 合并冲突。\n- 新 package 只登记进一个 aggregate。包同时具有 Node loader 入口和 browser 入口并不构成拆分理由;普通 Client plugin 的两份运行时产物都在 Client 构建阶段生成。\n\n`api/remotes` 是唯一拆分 Host/Client tsconfig 的仓库特例。它的 Host 入口必须进入 Host TypeRT 图,而 Client 入口导入 Host tsdown 才会生成的 `/remote` 声明,因此本包根 `tsconfig.json` 只作为 solution,两个 aggregate 和直接消费方分别引用 `tsconfig.host.json` 或 `tsconfig.client.json`。workspace `constraints` 门禁遍历可达的 Project Reference 图,并按各引用 project 自身的 compiler face 检查:只有单一配置的目标可由任一 face 引用,拆分配置的目标则必须引用匹配的 leaf,不得引用 solution 根或另一侧 leaf。不要把该结构推广到其他包;完整边界见 [`api-remotes` README](../packages/api/remotes/README.md)。\n\n根构建按生成依赖排序:\n\n```sh\ntsc -b tsconfig.host.json\ntsdown --env.DSH_BUILD_FACE host\ntsc -b tsconfig.client.json\ntsdown --env.DSH_BUILD_FACE client\npnpm run build:web\n```\n\n两次 tsdown 都使用同一组完整 workspace 匹配,不扫描构建产物来发现 Client package,也不维护 Host/Client package 过滤表。包内 tsdown 配置根据 `DSH_BUILD_FACE` 决定当前阶段的入口:普通 Client plugin 在 Client 阶段同时生成 Node loader 与 browser bundle;`api-remotes` 通过 `hostPhase: true` 提前生成 Host 入口,再在 Client 阶段只生成 browser bundle。tsdown 只消费 `lib/types` 中由前置 tsc 发射的 JavaScript。\n\nTypeRT 只在 Host tsdown 中以 `tsconfig.host.json` 为种子运行。它分析 Host 类型并生成 Host 反射产物及 Host-for-Client Remote 投影;Client tsdown 不启动 TypeRT。`pnpm run typecheck` 因此先执行完整 Host lib 阶段,再运行 Client tsc;`pnpm run build` 继续执行 Client tsdown 和 Web 构建。该顺序的决策记录见 [API Remotes 生成契约构建 Note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md)。\n\n静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。双 aggregate 拓扑见 [solution-root Note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md),tsc-first 发射职责见 [ts-build-config Note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md)。\n\n业务 Service 在 Host 使用 `@Remote` 或 `@RemoteScope` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `api-remotes` 组合加载这些贡献并挂到 `ctx.remote` 与作用域 `agentCtx.remote` namespace。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.md)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n### 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n### Git 钩子\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 应用仅用于格式化的 ESLint 修复,使用 Oxlint 验证暂存文件并应用其原生修复,在暂存文件属于 `THIRD_PARTY_NOTICES.md` 的输入时重新生成该文件,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-push` 只运行仓库增量类型检查(对根 solution 执行 `tsc -b`,覆盖 host 与 client 两个聚合)。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于两个 Git 钩子,也不是对 agent 的指令。\n\n### CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n### 日常命令\n\n根目录的[贡献者说明](../AGENTS.md#commands)概述常用命令,[`package.json`](../package.json) 与 [scripts/run-gates.ts](../scripts/run-gates.ts) 则负责当前脚本和门禁清单。请选择覆盖变更表面的最小检查集。文档变更使用 `pnpm run doc-sync`;package 公开行为变更还需更新所属 README 或 JSDoc,而基于构建产物的检查需要先运行 `pnpm run build`。\n\n### 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\n自指的 cordis 演示可以检查并修改其实时插件运行时,并需要相同的凭证(默认 `web`,也可用 `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n### 逐字记录类型(`ts type-equiv`)\n\n[核心数据结构](core-data-structures/core.md)文档会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码契约。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n" }, { "role": "user",