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 001/109] 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 002/109] 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 8d92a9bdaaebf79f45027a48ada60212416b20dc Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 11:43:44 +0800 Subject: [PATCH 003/109] fix(user-interaction): reject ask_user_question from delegated subagents --- ...-ask-user-delegated-caller-guard.i18n.yaml | 6 ++++ ...6-08-01-ask-user-delegated-caller-guard.md | 29 +++++++++++++++ ...8-01-ask-user-delegated-caller-guard.zh.md | 29 +++++++++++++++ packages/ui/tool-ask-user/README.i18n.yaml | 4 +-- packages/ui/tool-ask-user/README.md | 1 + packages/ui/tool-ask-user/README.zh.md | 1 + .../tool-ask-user/tests/tool-ask-user.spec.ts | 33 ++++++++++++++++- packages/ui/user-interaction/README.i18n.yaml | 4 +-- packages/ui/user-interaction/README.md | 4 +-- packages/ui/user-interaction/README.zh.md | 4 +-- packages/ui/user-interaction/src/index.ts | 12 +++++++ .../tests/user-interaction.spec.ts | 35 +++++++++++++++++++ 12 files changed, 153 insertions(+), 9 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-01-ask-user-delegated-caller-guard.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-01-ask-user-delegated-caller-guard.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-01-ask-user-delegated-caller-guard.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-08-01-ask-user-delegated-caller-guard.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-01-ask-user-delegated-caller-guard.i18n.yaml new file mode 100644 index 0000000000..800a575f3b --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-01-ask-user-delegated-caller-guard.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/bug-fix/2026-08-01-ask-user-delegated-caller-guard.md +2026-08-01-ask-user-delegated-caller-guard.md: 17c5e42a1d12c018507c6cf410129bb17099e967 +2026-08-01-ask-user-delegated-caller-guard.zh.md: 38f059ba87ac5208cca6cb94ba9ee5223a6987b0 diff --git a/.agents/notes/implemented/bug-fix/2026-08-01-ask-user-delegated-caller-guard.md b/.agents/notes/implemented/bug-fix/2026-08-01-ask-user-delegated-caller-guard.md new file mode 100644 index 0000000000..17c5e42a1d --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-01-ask-user-delegated-caller-guard.md @@ -0,0 +1,29 @@ +# Agent Note: Reject ask_user_question from delegated subagents + +Status: implemented + +English | [中文](2026-08-01-ask-user-delegated-caller-guard.zh.md) + +## Problem + +A delegated subagent that calls the `ask_user_question` tool blocks indefinitely. The tool pauses for a human answer, but a child context has no human answerer, so no answer ever arrives and the subagent run hangs until it is cancelled externally. + +## Decision + +`UserInteractionService.ask()` rejects any request whose calling agent is a delegated subagent — `request.agent.session.header.delegationDepth > 0` — with a new `UserInteractionError` code `DELEGATED_CALLER` and the message `ask_user_question is unavailable to delegated subagents; delegate the question to the top-level agent`. The check runs at the top of `ask()`, after the aborted/empty guards and before intent validation, so no provider interaction happens for a rejected child. This mirrors the goal tools' top-level-only authority (`create_goal` rejects non-top-level agents with a direct-human-turn requirement). + +## Alternatives considered + +**Leave the child blocked until the parent forwards an answer.** Rejected: no answerer exists in the child context and no forwarding seam exists; the observed behavior is a permanent hang. + +**Reject inside the tool (`dsh-tool-ask-user`) instead of the service.** Rejected: that consumer seam is bypassed by direct callers of `ctx.userInteraction.ask()`; the operation boundary that owns the decision is the service itself. + +**Warn children off via the model-facing description.** Rejected: the rejection is already a loud, self-explanatory error, and a description edit would not stop the hang for a model that calls anyway. + +## Consequences + +Delegated subagent calls fail fast with a stable error instead of hanging; a child that needs a decision must delegate the question to the top-level agent. Programmatic askers without an agent and top-level agents (`delegationDepth` absent or 0) are unaffected and still reach the provider. The `DELEGATED_CALLER` code joins the documented `UserInteractionError` taxonomy in the package READMEs, and the model-facing description is unchanged. + +## Testing + +Two new unit tests exercise the guard: `user-interaction.spec.ts` asserts that `ask()` rejects with `DELEGATED_CALLER` and never calls the provider for a session created with `{ meta: { delegationDepth: 1 } }`, plus a positive control at `delegationDepth: 0`; `tool-ask-user.spec.ts` asserts that a tool call from a delegated agent surfaces the structured error and never reaches the provider. Both packages pass, as does the parent `packages/ui` scope, and the two touched `src` files hold 100% per-file coverage. diff --git a/.agents/notes/implemented/bug-fix/2026-08-01-ask-user-delegated-caller-guard.zh.md b/.agents/notes/implemented/bug-fix/2026-08-01-ask-user-delegated-caller-guard.zh.md new file mode 100644 index 0000000000..38f059ba87 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-01-ask-user-delegated-caller-guard.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 拒绝委托子代理调用 ask_user_question + +Status: implemented + +[English](2026-08-01-ask-user-delegated-caller-guard.md) | 中文 + +## 问题 + +委托子代理调用 `ask_user_question` 工具时会无限阻塞。该工具会暂停等待人类回答,但子代理上下文中没有人类应答者,因此永远等不到回答,子代理运行只能被外部取消。 + +## 决策 + +`UserInteractionService.ask()` 拒绝任何调用方为委托子代理的请求 —— `request.agent.session.header.delegationDepth > 0` —— 抛出新的 `UserInteractionError`,代码为 `DELEGATED_CALLER`,消息为 `ask_user_question is unavailable to delegated subagents; delegate the question to the top-level agent`。该检查位于 `ask()` 开头,在已中止/空问题守卫之后、意图校验之前,因此被拒绝的子代理不会触发任何提供方交互。这与 goal 工具仅限顶层代理的权限保持一致(`create_goal` 以直接人工回合要求拒绝非顶层代理)。 + +## 备选方案 + +**让子代理一直阻塞,直到父代理转发回答。** 不予采用:子代理上下文中不存在应答者,也没有任何转发 seam;实际观察到的行为就是永久挂起。 + +**在工具(`dsh-tool-ask-user`)而非服务中拒绝。** 不予采用:直接调用 `ctx.userInteraction.ask()` 的调用方会绕过该消费方 seam;拥有此决策权的操作边界是服务本身。 + +**通过模型侧描述来警告子代理。** 不予采用:拒绝本身已是响亮且自解释的错误,而且修改描述并不能阻止仍然去调用的模型造成挂起。 + +## 影响 + +委托子代理的调用会以稳定错误快速失败,而不是挂起;需要决策的子代理必须把问题转交给顶层代理。不带 agent 的程序化调用方以及顶层代理(`delegationDepth` 缺省或为 0)不受影响,仍会到达提供方。`DELEGATED_CALLER` 代码已加入包 README 中记载的 `UserInteractionError` 分类,模型侧描述保持不变。 + +## Testing + +两个新的单元测试覆盖该守卫:`user-interaction.spec.ts` 断言以 `{ meta: { delegationDepth: 1 } }` 创建的会话调用 `ask()` 会以 `DELEGATED_CALLER` 拒绝且绝不调用提供方,并补充了 `delegationDepth: 0` 的正向对照;`tool-ask-user.spec.ts` 断言委托子代理发出的工具调用会呈现结构化错误且绝不触达提供方。两个包均通过,父级 `packages/ui` 作用域也通过,且两个被改动的 `src` 文件保持 100% 逐文件覆盖率。 diff --git a/packages/ui/tool-ask-user/README.i18n.yaml b/packages/ui/tool-ask-user/README.i18n.yaml index 7b8e667b58..869a5988c1 100644 --- a/packages/ui/tool-ask-user/README.i18n.yaml +++ b/packages/ui/tool-ask-user/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/tool-ask-user/README.md -README.md: 8e779f4025c20cd200344efb7cb8cd6bc09ba64d -README.zh.md: acaffec0764404a0e0e842ffc2b4efdee8869c4f +README.md: d7866ff018ebfed5afbf105b1a20714490bdb818 +README.zh.md: 18a1c8e9f958c174fc34f26a572d88b6c031d7f9 diff --git a/packages/ui/tool-ask-user/README.md b/packages/ui/tool-ask-user/README.md index 8e779f4025..d7866ff018 100644 --- a/packages/ui/tool-ask-user/README.md +++ b/packages/ui/tool-ask-user/README.md @@ -54,4 +54,5 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work - **A pending question blocks the tool call until the human answers** — the tool declares no `timeout-policy` budget; cancellation rides the turn's `exec.signal` only. +- **Delegated subagents cannot ask the user** — `ask_user_question` rejects calls from a delegated subagent with `DELEGATED_CALLER`; a child that needs a decision must delegate the question to the top-level agent. - **Native answers render as JSON text** — the canonical value remains structured, but the model-facing result uses compact JSON rather than a richer content-block vocabulary. diff --git a/packages/ui/tool-ask-user/README.zh.md b/packages/ui/tool-ask-user/README.zh.md index acaffec076..18a1c8e9f9 100644 --- a/packages/ui/tool-ask-user/README.zh.md +++ b/packages/ui/tool-ask-user/README.zh.md @@ -54,4 +54,5 @@ ## 已知限制与暂缓事项 - **待处理问题会阻塞工具调用,直至用户作答**:该工具未声明 `timeout-policy` 预算;取消仅沿用当前轮次的 `exec.signal`。 +- **委托的子代理不能向用户提问**:`ask_user_question` 会以 `DELEGATED_CALLER` 拒绝来自委托子代理的调用;需要决策的子代理必须把问题转交给顶层代理。 - **Native 回答渲染为 JSON 文本**:规范值仍为结构化数据,但模型侧结果使用紧凑 JSON,而非更丰富的内容块词汇。 diff --git a/packages/ui/tool-ask-user/tests/tool-ask-user.spec.ts b/packages/ui/tool-ask-user/tests/tool-ask-user.spec.ts index 395986aed1..4c9e572c40 100644 --- a/packages/ui/tool-ask-user/tests/tool-ask-user.spec.ts +++ b/packages/ui/tool-ask-user/tests/tool-ask-user.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { CallId } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' +import SessionStore from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import UserInteractionService, { type AskUserQuestionRequest } from '@deepseek-ai/dsh-user-interaction' @@ -201,6 +202,7 @@ describe('ask_user_question tool', () => { it('passes optional header and agent through to the user-interaction request', async () => { const ctx = await setup() + await ctx.plugin(SessionStore) const seen: AskUserQuestionRequest[] = [] ctx.userInteraction.registerProvider({ async ask(request) { @@ -208,7 +210,8 @@ describe('ask_user_question tool', () => { return { answers: [{ id: 'continue', selected: ['ok'] }] } }, }) - const agent = { id: 'main' } as unknown as Agent + const session = ctx.sessions.create(undefined, { meta: { delegationDepth: 0 } }) + const agent = { session } as unknown as Agent const result = await ctx.tools.execute({ signal: testToolSignal, @@ -238,6 +241,34 @@ describe('ask_user_question tool', () => { }) }) + it('rejects a delegated subagent with a structured DELEGATED_CALLER error', async () => { + const ctx = await setup() + await ctx.plugin(SessionStore) + const seen: AskUserQuestionRequest[] = [] + ctx.userInteraction.registerProvider({ + async ask(request) { + seen.push(request) + return { answers: [{ id: 'continue', selected: ['ok'] }] } + }, + }) + const session = ctx.sessions.create(undefined, { meta: { delegationDepth: 1 } }) + const agent = { session } as unknown as Agent + + const result = await ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('ask-delegated'), + name: 'ask_user_question', + arguments: { questions: [{ id: 'continue', question: 'Continue?' }] }, + agent, + }) + + expect(result).toMatchObject({ + isError: true, + error: { info: { name: 'UserInteractionError', code: 'DELEGATED_CALLER' } }, + }) + expect(seen).toHaveLength(0) + }) + it('returns a structured error for empty question batches', async () => { const ctx = await setup() diff --git a/packages/ui/user-interaction/README.i18n.yaml b/packages/ui/user-interaction/README.i18n.yaml index a74e6ec71b..7d2ea23b14 100644 --- a/packages/ui/user-interaction/README.i18n.yaml +++ b/packages/ui/user-interaction/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/user-interaction/README.md -README.md: d62e75d110b8be339c5f9449b0834320f695ac99 -README.zh.md: 55258e85e56df2375ed8f195fa0b3b731a9cb816 +README.md: 3459f915f2cd94d4083975440731661d8aeb9108 +README.zh.md: 26d40e98dbcc15ef18a85cd98205defb765d4469 diff --git a/packages/ui/user-interaction/README.md b/packages/ui/user-interaction/README.md index d62e75d110..3459f915f2 100644 --- a/packages/ui/user-interaction/README.md +++ b/packages/ui/user-interaction/README.md @@ -18,7 +18,7 @@ Abstract user-interaction seam. It owns `ctx.userInteraction`, the service a mod - `AskUserQuestionIntent` — `{ kind: 'plan-review', approve }`; the tagged presentation intent below. - `AskUserQuestionAnswer` — `{ answers: [{ id, selected, custom? }] }`. - `UserInteractionProvider` — UI implementation with `ask(request)`. -- `UserInteractionError` — `HarnessError` subclass with codes such as `EMPTY_QUESTIONS`, `BAD_INTENT`, `NO_PROVIDER`, `DUPLICATE_PROVIDER`, and `ASK_ABORTED`. +- `UserInteractionError` — `HarnessError` subclass with codes such as `EMPTY_QUESTIONS`, `BAD_INTENT`, `NO_PROVIDER`, `DUPLICATE_PROVIDER`, `ASK_ABORTED`, and `DELEGATED_CALLER`. When an answer includes `custom`, `selected` is empty; custom text is an override rather than a supplement to selected choices. A UI may preserve a skipped item as `{ id, selected: [] }`, keeping the existing answer shape while retaining other answers in the batch. @@ -32,7 +32,7 @@ This is the interface package. Model-facing consumers such as `@deepseek-ai/dsh- ## Model Experience -Indirectly, through `dsh-tool-ask-user`, which retains a successful provider answer as compact JSON or one of these failures: `Error: ask_user_question was aborted before the user answered`, `Error: ask_user_question requires at least one question`, `Error: no user-interaction provider is registered`, or `Error: `. Waiting for the human adds no tokens. +Indirectly, through `dsh-tool-ask-user`, which retains a successful provider answer as compact JSON or one of these failures: `Error: ask_user_question was aborted before the user answered`, `Error: ask_user_question requires at least one question`, `Error: ask_user_question is unavailable to delegated subagents; delegate the question to the top-level agent`, `Error: no user-interaction provider is registered`, or `Error: `. Waiting for the human adds no tokens. #### KV Cache effect diff --git a/packages/ui/user-interaction/README.zh.md b/packages/ui/user-interaction/README.zh.md index 55258e85e5..26d40e98db 100644 --- a/packages/ui/user-interaction/README.zh.md +++ b/packages/ui/user-interaction/README.zh.md @@ -18,7 +18,7 @@ - `AskUserQuestionIntent`:`{ kind: 'plan-review', approve }`;即下文的带标签呈现意图。 - `AskUserQuestionAnswer`:`{ answers: [{ id, selected, custom? }] }`。 - `UserInteractionProvider`:包含 `ask(request)` 的 UI 实现。 -- `UserInteractionError`:`HarnessError` 的子类,包含 `EMPTY_QUESTIONS`、`BAD_INTENT`、`NO_PROVIDER`、`DUPLICATE_PROVIDER` 和 `ASK_ABORTED` 等代码。 +- `UserInteractionError`:`HarnessError` 的子类,包含 `EMPTY_QUESTIONS`、`BAD_INTENT`、`NO_PROVIDER`、`DUPLICATE_PROVIDER`、`ASK_ABORTED` 和 `DELEGATED_CALLER` 等代码。 当回答包含 `custom` 时,`selected` 为空;自定义文本是所选选项的替代,而不是补充。UI 可以把跳过的条目保留为 `{ id, selected: [] }`,既维持现有回答形态,也保留该批次中的其他回答。 @@ -32,7 +32,7 @@ ## 模型体验 -间接地,通过 `dsh-tool-ask-user`:它会将成功的提供方回答保留为紧凑 JSON,或返回以下失败之一:`Error: ask_user_question was aborted before the user answered`、`Error: ask_user_question requires at least one question`、`Error: no user-interaction provider is registered` 或 `Error: `。等待人类回答不会增加 token。 +间接地,通过 `dsh-tool-ask-user`:它会将成功的提供方回答保留为紧凑 JSON,或返回以下失败之一:`Error: ask_user_question was aborted before the user answered`、`Error: ask_user_question requires at least one question`、`Error: ask_user_question is unavailable to delegated subagents; delegate the question to the top-level agent`、`Error: no user-interaction provider is registered` 或 `Error: `。等待人类回答不会增加 token。 #### KV Cache 影响 diff --git a/packages/ui/user-interaction/src/index.ts b/packages/ui/user-interaction/src/index.ts index 506b3c6bfe..b7e76c1d47 100644 --- a/packages/ui/user-interaction/src/index.ts +++ b/packages/ui/user-interaction/src/index.ts @@ -77,8 +77,15 @@ export class UserInteractionService extends Service { /** * Ask the active UI provider and wait for the user's answer. * + * Human-interaction requests are only valid from a top-level agent: a + * delegated subagent has no human answerer in its own context, so asking + * there would block forever. This mirrors the goal tools' top-level-only + * authority (`create_goal` rejects non-top-level agents). + * * @param request Questions, owner agent, and abort signal. * @returns The answer chosen or typed by the human. + * @throws {UserInteractionError} code `DELEGATED_CALLER` when the calling + * agent is a delegated subagent (`session.header.delegationDepth > 0`). */ async ask(request: AskUserQuestionRequest): Promise { if (request.signal?.aborted) { @@ -87,6 +94,11 @@ export class UserInteractionService extends Service { if (request.questions.length === 0) { throw new UserInteractionError('ask_user_question requires at least one question', 'EMPTY_QUESTIONS') } + if ((request.agent?.session.header.delegationDepth ?? 0) > 0) { + throw new UserInteractionError( + 'ask_user_question is unavailable to delegated subagents; delegate the question to the top-level agent', + 'DELEGATED_CALLER') + } // A presentation intent asserts two things the types cannot: that the // named approve label is one of this question's own options, and that a // plan-review carries the plan it is a review of. A UI honouring the diff --git a/packages/ui/user-interaction/tests/user-interaction.spec.ts b/packages/ui/user-interaction/tests/user-interaction.spec.ts index df6b878cbd..30fdf49a7e 100644 --- a/packages/ui/user-interaction/tests/user-interaction.spec.ts +++ b/packages/ui/user-interaction/tests/user-interaction.spec.ts @@ -1,5 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import SessionStore from '@deepseek-ai/dsh-session' import UserInteractionService, { UserInteractionError, type AskUserQuestionRequest, @@ -84,6 +86,39 @@ describe('UserInteractionService', () => { expect(p.ask).not.toHaveBeenCalled() }) + it('rejects a delegated subagent before reaching the provider', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(UserInteractionService) + const p = { ask: vi.fn(async () => ({ answers: [] })) } + ctx.userInteraction.registerProvider(p) + const session = ctx.sessions.create(undefined, { meta: { delegationDepth: 1 } }) + const agent = { session } as unknown as Agent + + await expect(ctx.userInteraction.ask({ + questions: [{ id: 'confirm', question: 'Proceed?' }], + agent, + })).rejects.toMatchObject({ name: 'UserInteractionError', code: 'DELEGATED_CALLER' }) + expect(p.ask).not.toHaveBeenCalled() + }) + + it('still reaches the provider for a top-level agent (delegationDepth 0)', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(UserInteractionService) + const p = provider('yes') + ctx.userInteraction.registerProvider(p) + const session = ctx.sessions.create(undefined, { meta: { delegationDepth: 0 } }) + const agent = { session } as unknown as Agent + + const result = await ctx.userInteraction.ask({ + questions: [{ id: 'confirm', question: 'Proceed?' }], + agent, + }) + + expect(result).toEqual({ answers: [{ id: 'confirm', selected: ['yes'] }] }) + }) + it('rejects an intent whose approve label names none of its own options', async () => { const ctx = new Context() await ctx.plugin(UserInteractionService) From 46d8d97efec7aec57ac3a280a8b5956c0b13ccf9 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 11:45:33 +0800 Subject: [PATCH 004/109] chore(cordis): regenerate service catalog for user-interaction JSDoc --- docs/cordis-catalog/services.md | 7 +++++++ packages/cordis/tool-cordis/src/api-catalog.ts | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 22bc6343ea..dee9c09f38 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2445,8 +2445,15 @@ registerProvider(provider: UserInteractionProvider): () => void /** * Ask the active UI provider and wait for the user's answer. * + * Human-interaction requests are only valid from a top-level agent: a + * delegated subagent has no human answerer in its own context, so asking + * there would block forever. This mirrors the goal tools' top-level-only + * authority (`create_goal` rejects non-top-level agents). + * * @param request Questions, owner agent, and abort signal. * @returns The answer chosen or typed by the human. + * @throws {UserInteractionError} code `DELEGATED_CALLER` when the calling + * agent is a delegated subagent (`session.header.delegationDepth > 0`). */ async ask(request: AskUserQuestionRequest): Promise ``` diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 2beb8a3c77..7089ee0f5d 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1114,7 +1114,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'async ask(request: AskUserQuestionRequest): Promise', - jsDoc: '/**\n * Ask the active UI provider and wait for the user\'s answer.\n *\n * @param request Questions, owner agent, and abort signal.\n * @returns The answer chosen or typed by the human.\n */', + jsDoc: '/**\n * Ask the active UI provider and wait for the user\'s answer.\n *\n * Human-interaction requests are only valid from a top-level agent: a\n * delegated subagent has no human answerer in its own context, so asking\n * there would block forever. This mirrors the goal tools\' top-level-only\n * authority (`create_goal` rejects non-top-level agents).\n *\n * @param request Questions, owner agent, and abort signal.\n * @returns The answer chosen or typed by the human.\n * @throws {UserInteractionError} code `DELEGATED_CALLER` when the calling\n * agent is a delegated subagent (`session.header.delegationDepth > 0`).\n */', }, ], }, From 88c035c98e2992641d390bd083be400da5d7d3c2 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 4 Aug 2026 14:11:38 +0800 Subject: [PATCH 005/109] 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 006/109] 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 007/109] 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 008/109] 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 009/109] 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 010/109] 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 011/109] 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 012/109] 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 013/109] 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 014/109] 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 015/109] 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 016/109] 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 017/109] 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 018/109] 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 598f9719f4e27b6d5e37478fb20a85214956fd0d Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 6 Aug 2026 10:23:26 +0800 Subject: [PATCH 019/109] refactor(landlock-run): unify workspace release (review round 1) --- .../feature/2026-07-06-sandbox.i18n.yaml | 4 +- .../implemented/feature/2026-07-06-sandbox.md | 12 +- .../feature/2026-07-06-sandbox.zh.md | 12 +- ...6-in-repository-landlock-release.i18n.yaml | 6 + ...26-08-06-in-repository-landlock-release.md | 42 +++ ...08-06-in-repository-landlock-release.zh.md | 42 +++ .github/workflows/landlock-run-release.yml | 170 +++++++++ .github/workflows/landlock-run.yml | 42 ++- .github/workflows/sandbox.yml | 27 +- THIRD_PARTY_NOTICES.md | 4 +- knip.json | 1 + native/README.i18n.yaml | 4 +- native/README.md | 10 +- native/README.zh.md | 10 +- native/landlock-run/AGENTS.md | 2 +- native/landlock-run/docs/release.md | 36 +- native/landlock-run/pnpm-lock.yaml | 345 ------------------ native/landlock-run/pnpm-workspace.yaml | 8 - native/landlock-run/scripts/bump-release.mjs | 11 +- .../landlock-run/scripts/commit-release.mjs | 10 +- native/landlock-run/scripts/repo.mjs | 4 +- .../landlock-run/scripts/verify-release.mjs | 16 +- package.json | 2 + packages/bash/bash-sandbox/package.json | 2 +- packages/bash/bash-sandbox/tsconfig.json | 3 + .../examples/agent-spine-demo/package.json | 2 +- .../examples/agent-spine-demo/tsconfig.json | 3 + packages/sandbox/sandbox-local/package.json | 2 +- .../sandbox-local/tests/landlock.e2e.ts | 2 +- .../sandbox-local/tests/packed-install.e2e.ts | 35 +- packages/sandbox/sandbox-local/tsconfig.json | 3 + pnpm-lock.yaml | 103 +++--- pnpm-workspace.yaml | 11 +- scripts/check-workspace-constraints.ts | 32 +- scripts/clean.spec.ts | 16 +- scripts/clean.ts | 9 +- scripts/gen-third-party-notices.spec.ts | 4 +- scripts/gen-third-party-notices.ts | 16 +- tsconfig.base.json | 1 + tsconfig.host.json | 1 + 40 files changed, 535 insertions(+), 530 deletions(-) create mode 100644 .agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.md create mode 100644 .agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.zh.md create mode 100644 .github/workflows/landlock-run-release.yml delete mode 100644 native/landlock-run/pnpm-lock.yaml delete mode 100644 native/landlock-run/pnpm-workspace.yaml diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml index 7f15a55333..5f8dfa4e65 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.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-06-sandbox.md -2026-07-06-sandbox.md: aed5ac1ceb02130ce97a8c83c0f77869fdc32146 -2026-07-06-sandbox.zh.md: db95b1a5b7a7cae1e0fcdd8deba9dcb6ad020a67 +2026-07-06-sandbox.md: 69a3f1bd181bc06d9a176fa45b1e091991cfa682 +2026-07-06-sandbox.zh.md: eeca55b61da24df215f7a9b7ba8dbf9ab2387f20 diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.md index aed5ac1ceb..69a3f1bd18 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.md @@ -64,7 +64,7 @@ Left open, for the phase that needs them: whether network restriction arrives as The launcher is a ~300-line C program (plain C11 over the raw Landlock UAPI — no libraries beyond a statically linked musl, so the audit surface is that one file plus the kernel's stable syscall contract): `--ro ` / `--rw ` grants, `--`, the wrapped argv; it installs the ruleset on itself and `exec`s (rulesets are inherited across `execve`, and it sets `no_new_privs` before restricting); `--probe` enforces a maximal ruleset in a short-lived child and exits 0 only when the kernel actually enforces; every launcher failure exits 125 without running the child and prints a fatal `landlock-run:` line. A successfully exec'd child may also return 125, so status alone is not launcher evidence. An older ABI prints the exact `landlock-run: partial enforcement (older Landlock ABI)` notice before it executes the child, so that line is not fatal evidence. -The Landlock launcher source and package workspace live at `native/landlock-run`, next to the harness consumers. The standalone [`node-addon-landlock-run`](https://github.com/deepseek-harness/node-addon-landlock-run) repository is the release mirror used to pack and publish the npm package family; `native/README.md` owns the export procedure. Platform binaries are selected by npm, and the entry package owns path resolution, probing, CLI flags, the fatal prefix, and the partial-enforcement notice while the harness maps sandbox modes to grants. Versioning the entry point with its binaries keeps probe parsing and launch syntax aligned. +The Landlock launcher source and package family live at `native/landlock-run`, next to the harness consumers and inside the root pnpm workspace. The [in-repository Landlock release decision](../process/2026-08-06-in-repository-landlock-release.md) owns the shared lockfile, native build, pack rehearsal, and npm publication boundary. Platform binaries are selected by npm, and the entry package owns path resolution, probing, CLI flags, the fatal prefix, and the partial-enforcement notice while the harness maps sandbox modes to grants. Versioning the entry point with its binaries keeps probe parsing and launch syntax aligned. Backend profiles share the mode contract but differ in necessary host grants. Landlock and Seatbelt allow only `/dev/null` in read-only mode; workspace-write also permits their required host temp roots. Each wrap carries backend-specific denial signatures. Landlock reports partial enforcement on older ABIs that cannot govern every operation, while successful bwrap and Seatbelt profiles report full enforcement. @@ -118,7 +118,7 @@ fs/web/todo execute in-process, so their sandbox semantics are policy at their s ### Testing - **Unit:** pin platform selection and profiles, direct provider-argv handoff, spawn-level failures with invalid-workdir controls, missing/non-executable/missing-interpreter evidence, malformed-runner negative controls, confined `BASH_ENV` ordering, structured runner classification (including partial-Landlock notice-only child outcomes, gated fatal evidence, child exits 126/127, and foreground/background parity), per-call mode/root resolution, per-process facts, escalation validation and outcomes, permission preset folding and write-through, and runtime-context ordering and materialization. -- **Keyless real-runner:** exercise bwrap, Landlock, and Seatbelt against real filesystem effects at provider and bash-consumer layers; one real Cordis context concurrently drives two project sessions through shipped bash and fs tools, proving own-root success and sibling-root denial. Packed-install coverage proves the registry launcher remains executable. CI rejects a silent all-skip. +- **Keyless real-runner:** exercise bwrap, Landlock, and Seatbelt against real filesystem effects at provider and bash-consumer layers; one real Cordis context concurrently drives two project sessions through shipped bash and fs tools, proving own-root success and sibling-root denial. Packed-install coverage installs the current checkout's native tarballs and proves the launcher remains executable and byte-identical. CI rejects a silent all-skip. - **With-key:** start the real ACP composition in read-only mode, let a model-driven bash write hit the runner's denial marker, then drive the bridge answerer and disk effect through granted and rejected workspace-write retries; unavailable credentials or runners self-skip. - **Snapshot:** pin the atomic current-policy context and both scripted approval branches. A real ACP example scenario places its session under the user home while the deployment fallback points at `/tmp`, then pins both the workspace-write runtime-context message and a successful deployment-selected mutation; this distinguishes session-root resolution from the process fallback without depending on runner-specific denial text. A POSIX fake partial-Landlock provider pins direct bash `false` as an ordinary child result and a missing provider executable as foreground/background infrastructure failure through the assembled app. Other snapshots start unconfined so unrelated fixtures remain platform-independent. @@ -134,9 +134,9 @@ Each phase gets its full design when picked up, validated against the code at th - **Command-string heuristic preflight** — rejected: cannot understand expansion/subprocesses/symlinks; the strict attempt (run it, let the kernel decide) is the only trustworthy denial signal. - **Functionally probe even a platform's sole backend** — rejected: probing arbitrates between candidates; with one there is nothing to decide, and probe cost taxes the first confined command of every session (prohibitive for heavy future backends). The runner's own exec-time fail-closed refusal plus structured `runnerFailureRules` classification carries the safety property instead. -- **Commit the built launcher binaries** — rejected: a binary in a diff is unreviewable and churns history; reviewed source + native CI builds + the launcher repo's byte-pinned publish rehearsal keep bytes out of every tree. +- **Commit the built launcher binaries** — rejected: a binary in a diff is unreviewable and churns history; reviewed source + native CI builds + the main repository's byte-pinned publish rehearsal keep bytes out of every tree. - **Compile the launcher on install** — rejected: pushes a C toolchain onto every consumer; a fallback that exists only where a compiler happens to be is not a fallback. -- **Cross-compile both architectures from one builder** — rejected: requires carrying a pinned cross toolchain (rustup targets, zig, or a container image) solely to rebuild two ~70 KB binaries; per-architecture native runners already exist and each builds its own platform package (the `node-addon-require-builtin` model, the launcher repo's own pipeline). +- **Cross-compile both architectures from one builder** — rejected: requires carrying a pinned cross toolchain (rustup targets, zig, or a container image) solely to rebuild two ~70 KB binaries; per-architecture native runners already exist and each builds its own platform package (the `node-addon-require-builtin` model, retained by the main repository's native pipeline). - **No fallback (bwrap or fail closed)** — rejected: concentrates failure on the hosts a sandbox matters most, degrading to `danger-full-access` by resignation. - **Keep the mechanism inside `dsh-bash-sandbox`** — rejected: blocks the existing second consumer, makes future phases read mode out of a bash plugin's config, and cannot express escalation. - **Config-fixed mode on the provider** — rejected: one mode per process; cannot serve concurrent consumers with different policies nor the one-shot widened retry. @@ -175,7 +175,7 @@ Costs and accepted limits: - **The Seatbelt rung leans on Apple's deprecated-but-shipped `sandbox-exec` CLI.** As darwin's sole candidate it is selected without probing, so a future removal under a usable workdir surfaces as a runner-attributable spawn failure and an executable refusal through its fatal signature — both become `SANDBOX_UNAVAILABLE`, and the command never runs; fail closed, never open. - **Landlock confinement is only as complete as the running kernel's ABI.** Reported as `enforcement: 'partial'` rather than refused — the deliberate trade that keeps the fallback available on older-kernel hosts. - **Runner attribution uses an in-band protocol.** Exit status plus stderr cannot cryptographically identify the writer, so a confined child can mimic a fatal runner line and status to cause an availability/diagnostic false attribution. The conjunction and exact notice exclusion reduce accidental matches; this is not a sandbox bypass because the child is already confined. -- **The launcher arrives as a registry dependency.** Trusted through its own repository's release pipeline (reviewed C source, native CI builders, byte-pinned publish rehearsal) plus this repo's version pin — the real-kernel e2e legs are what vouch for behavior through the installed bytes. +- **The launcher is a workspace dependency in source and an npm dependency after publication.** The main repository tests reviewed C source, native CI builds, and byte-pinned local tarballs together before publishing the same package family; the real-kernel e2e legs vouch for behavior through those installed bytes. - **The model may over-ask.** Escalating without denial grounding, or picking `danger-full-access` where `workspace-write` suffices: the description steers and the enum forces the ladder, but the human prompt is the actual gate; the `approval/asked` reasons make over-asking auditable, and a `prepend` policy answerer can auto-reject patterns a deployment never wants. - **The advertised target set is static while the effective mode is per-session** (schemas are registry-global) — a session already at the widest mode is still offered the fields. Harmless by construction: the strict-wider check at execution, not the enum, is the safety boundary — a non-widening request fails with its own text and never prompts anyone. - **A granted escalation is not a working sandbox.** An unavailable backend still fails closed even for a granted escalation to a confining mode — at `confine()` when the platform has no chain or every probe fails, through the spawn channel when the selected executable cannot start, or through a structured rule when a started runner refuses — while a granted `danger-full-access` run never touches the provider at all: there the grant, not the probe, is the authority. @@ -187,7 +187,7 @@ Costs and accepted limits: - **A command came back with `[sandbox: file access denied under read-only mode]` — did it fail?** It RAN, and the kernel refused a file effect: the denial is a result fact orthogonal to exit code. The teaching forbids retrying around it; the one sanctioned move is the same command retried once with an escalation request. - **How is a BROKEN sandbox told apart from a failing command?** Any provider-argv spawn rejection proves the confined launch never started, but it identifies a broken runner only when the caller-owned workdir is usable and Node reports attributable `ENOENT` or `EACCES` for that argv[0]. A bare `syscall: 'spawn'` without an exact error path and all other rejections remain ordinary command-start errors. After a process starts, runner failure outranks denial only when one `runnerFailureRules` entry matches both its optional exit-code gate and a fatal stderr line after exact informational exclusions. Foreground failures throw structured `SANDBOX_UNAVAILABLE` with spawn or matched-line detail; an asynchronously rejected or settled background task stamps `sandbox.runnerFailed` and renders its own marker. A `SubprocessService` that synchronously throws the same provenanced `ENOENT`/`EACCES` shape makes background start throw the structured error; other synchronous errors propagate unchanged. A Landlock partial-enforcement notice plus an ordinary child failure remains a command result. - **What happens on a platform with no backend — Windows today?** `confine()` throws the fail-closed `SANDBOX_UNAVAILABLE` and the command never spawns; `win32` is a reserved EMPTY chain, pinned by test to fail closed identically until a Windows runner fills it (§ Deferred phases). -- **`bwrap` is installed on my host but unusable (disabled unprivileged userns, an LSM denying `mount`) — what happens?** The chain probe is functional — it builds and enforces a real profile rather than checking `--version` — so a present-but-unusable `bwrap` fails its probe, selection falls to the registry-installed Landlock launcher, and the verdict is cached for the provider's lifetime. +- **`bwrap` is installed on my host but unusable (disabled unprivileged userns, an LSM denying `mount`) — what happens?** The chain probe is functional — it builds and enforces a real profile rather than checking `--version` — so a present-but-unusable `bwrap` fails its probe, selection falls to the packaged Landlock launcher, and the verdict is cached for the provider's lifetime. - **Does the sandbox restrict network or process visibility?** No — `SandboxMode` claims FILE effects only; the bwrap profile deliberately does not unshare pid, and no backend claims network. Whether network restriction becomes its own knob is left open in § The seam. - **Which tools actually run confined?** OS subprocesses through `ctx.bash` — the bash tools, and hook commands transitively — plus the filesystem tools (`read`/`write`/`edit`) through the sandboxed `ctx.fs` provider (the [cross-family fs sandbox RFC](2026-07-14-cross-family-fs-sandbox.md)): bash confines via the OS runner, fs via an in-process path fence, both keying off the same `ctx.sandboxPolicy` mode. web/todo stay in-process and unfenced (web's only effect is network, outside the file-effect mode vocabulary). - **Does a granted escalation persist?** No. The grant is consumed by the exact foreground or background call that asked; every neighboring call keeps its own effective mode. A later background denial surfaces through `task_output` and may ground a new exact-command retry. diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md index db95b1a5b7..eeca55b61d 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md @@ -64,7 +64,7 @@ OS 子进程约束适用于 bash 执行器(包括钩子命令),后续还 launcher 是一个约 300 行的 C 程序(纯 C11,直接使用 Landlock UAPI——除静态链接的 musl 外无其他库,因此审计面仅为该文件加内核的稳定 syscall 契约):`--ro ` / `--rw ` 授权,`--`,被包装的 argv;它为自身安装规则集并执行 `exec`(规则集跨 `execve` 继承,且它在限制前设置 `no_new_privs`);`--probe` 在一个短生命周期子进程中强制最大规则集,仅当内核确实强制时才以 0 退出;所有 launcher 失败都会以 125 退出且不运行子进程,并打印一行致命的 `landlock-run:` 诊断。成功完成 exec 的子进程也可能返回 125,因此仅凭退出状态不能作为 launcher 失败的证据。较旧的 ABI 会在执行子进程之前打印精确的 `landlock-run: partial enforcement (older Landlock ABI)` 通知,因此该行不是致命证据。 -Landlock launcher 源码和包工作区位于 `native/landlock-run`,与 harness 消费方同仓。独立的 [`node-addon-landlock-run`](https://github.com/deepseek-harness/node-addon-landlock-run) 仓库是用于打包并发布 npm 包族的发布镜像;导出流程归 `native/README.md` 所有。平台二进制由 npm 选择,入口包拥有路径解析、探测、CLI 参数、致命前缀和部分强制执行通知,而 harness 将沙箱模式映射为授权。将入口点与其二进制一起版本化,使探测解析和启动语法保持对齐。 +Landlock launcher 源码和包家族位于 `native/landlock-run`,与 harness 消费方同仓,并属于根 pnpm workspace。[仓库内 Landlock 发布决策](../process/2026-08-06-in-repository-landlock-release.md)负责共享锁文件、原生构建、打包演练和 npm 发布边界。平台二进制由 npm 选择,入口包拥有路径解析、探测、CLI 参数、致命前缀和部分强制执行通知,而 harness 将沙箱模式映射为授权。将入口点与其二进制一起版本化,使探测解析和启动语法保持对齐。 后端 profile 共享模式契约但在必要的主机授权上有所不同。Landlock 和 Seatbelt 在 read-only 模式下仅允许 `/dev/null`;workspace-write 还允许各自所需的主机临时目录根。每次包装携带后端特定的拒绝签名。Landlock 在较旧的 ABI 无法管控所有操作时报告 partial enforcement,而成功的 bwrap 和 Seatbelt profile 报告 full enforcement。 @@ -118,7 +118,7 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层 ### 测试 - **单元测试:** 固定平台选择和 profile、直接交接提供方返回的 argv、带有无效 workdir 对照的 spawn 层失败、runner 缺失/不可执行/解释器缺失证据、格式错误 runner 阴性对照、受约束的 `BASH_ENV` 求值顺序、结构化 runner 分类(包括只有部分强制执行通知的子进程结果、带门控的致命证据、子进程退出码 126/127,以及前台/后台一致性)、按调用的模式/根目录解析、按进程事实、升级验证和结果、权限 preset fold 和写入透传,以及运行时上下文排序与具体化。 -- **Keyless 真实 runner:** 在提供方和 bash 消费方层面对 bwrap、Landlock 和 Seatbelt 执行真实文件系统效果测试;一个真实 Cordis 上下文通过已交付的 bash 和 fs 工具并发驱动两个项目会话,证明在自身根目录写入成功、在兄弟根目录写入被拒绝。打包安装测试证明注册表 launcher 保持可执行。CI 拒绝静默全跳过。 +- **Keyless 真实 runner:** 在提供方和 bash 消费方层面对 bwrap、Landlock 和 Seatbelt 执行真实文件系统效果测试;一个真实 Cordis 上下文通过已交付的 bash 和 fs 工具并发驱动两个项目会话,证明在自身根目录写入成功、在兄弟根目录写入被拒绝。打包安装测试会安装当前 checkout 的原生 tarball,并证明 launcher 保持可执行且字节完全一致。CI 拒绝静默全跳过。 - **With-key:** 以只读模式启动真实 ACP 组合,让模型驱动的 bash 写入命中 runner 的拒绝标记,再通过已授权与被拒绝的 workspace-write 重试驱动 bridge 应答器和磁盘效果;不可用的凭证或 runner 自动跳过。 - **快照:** 固定原子化的当前策略上下文和两个脚本化的 approval 分支。一个真实 ACP 示例场景把会话放在用户主目录下,同时让部署后备根目录指向 `/tmp`,然后固定 workspace-write 运行时上下文消息与一次成功的、由部署选定的变更;这能区分会话根目录解析与进程后备值,而不依赖 runner 特定的拒绝文本。一个模拟 Landlock 部分强制执行行为的 POSIX 提供方会在组装后的应用中固定直接执行 bash `false` 时仍得到普通子进程结果,并固定提供方可执行文件缺失时在前台/后台均为基础设施失败。其他快照以无约束启动,使无关 fixture(测试前置数据)保持平台无关。 @@ -134,9 +134,9 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层 - **命令字符串启发式预检**:否决。无法理解展开/子进程/符号链接;严格尝试(运行它,让内核决定)是唯一可信的拒绝信号。 - **即使平台仅有一个后端也功能性探测**:否决。探测用于在候选者之间仲裁;只有一个时无需决策,且探测开销对每个会话的首次约束命令征税(对未来重量级后端而言代价过高)。runner 自身执行时的失败关闭拒绝加结构化 `runnerFailureRules` 分类承载了安全属性。 -- **提交构建好的 launcher 二进制**:否决。diff 中的二进制不可审查且膨胀历史;经审查的源码 + 原生 CI 构建 + launcher 仓库的字节固定发布演练使二进制远离所有代码树。 +- **提交构建好的 launcher 二进制**:否决。diff 中的二进制不可审查且膨胀历史;经审查的源码 + 原生 CI 构建 + 主仓库的字节固定发布演练使二进制远离所有代码树。 - **安装时编译 launcher**:否决。将 C 工具链强加给每个消费方;仅在碰巧有编译器时才存在的备选不是备选。 -- **从一个构建器交叉编译两种架构**:否决。仅为重建两个约 70 KB 的二进制就需要携带一个固定的交叉工具链(rustup targets、zig 或容器镜像);每架构的原生 runner 已存在,各自构建自己的平台包(`node-addon-require-builtin` 模式,launcher 仓库自己的流水线)。 +- **从一个构建器交叉编译两种架构**:否决。仅为重建两个约 70 KB 的二进制就需要携带一个固定的交叉工具链(rustup targets、zig 或容器镜像);每架构的原生 runner 已存在,各自构建自己的平台包(`node-addon-require-builtin` 模式,由主仓库的原生流水线保留)。 - **无备选(bwrap 或失败关闭)**:否决。将失败集中在沙箱最重要的主机上,最终因放弃而降级到 `danger-full-access`。 - **将机制保留在 `dsh-bash-sandbox` 内部**:否决。阻塞既有的第二个消费方,使未来阶段从一个 bash 插件的配置中读取模式,且无法表达升级。 - **提供方上的配置固定模式**:否决。每进程一个模式;无法服务具有不同策略的并发消费方,也无法表达一次性放宽重试。 @@ -175,7 +175,7 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层 - **Seatbelt 层级依赖 Apple 已弃用但仍交付的 `sandbox-exec` CLI。** 作为 darwin 的唯一候选,它无需探测即被选中,因此在 workdir 可用时,未来移除会表现为可归因于 runner 的 spawn 失败,可执行文件拒绝则通过其致命签名体现——两者都会变为 `SANDBOX_UNAVAILABLE`,且命令绝不会运行;失败关闭,绝不开放。 - **Landlock 约束的完整度取决于运行内核的 ABI。** 报告为 `enforcement: 'partial'` 而非拒绝——这是有意的权衡,使备选在旧内核主机上仍可用。 - **Runner 归因使用带内协议。** 退出状态与 stderr 无法以密码学方式识别写入者,因此受限子进程可以模仿 runner 的致命诊断行和状态,造成可用性或诊断误归因。多项证据的合取与精确通知排除减少了意外匹配;这不是沙箱绕过,因为子进程已经受到限制。 -- **launcher 作为注册表依赖到达。** 通过其自身仓库的发布流水线(经审查的 C 源码、原生 CI 构建器、字节固定的发布演练)加上本仓库的版本固定获得信任——真实内核 e2e 测试环节会验证安装产物的实际行为。 +- **launcher 在源码中是 workspace 依赖,发布后是 npm 依赖。** 主仓库会在发布同一个包家族之前,一起测试经审查的 C 源码、原生 CI 构建和字节固定的本地 tarball;真实内核 e2e 测试环节会验证这些安装字节的实际行为。 - **模型可能过度请求。** 在没有拒绝依据的情况下升级,或在 `workspace-write` 足够时选择 `danger-full-access`:描述引导且枚举强制阶梯,但人的提示词是实际门控;`approval/asked` 原因使过度请求可审计,且 `prepend` 策略应答器可以自动拒绝部署永远不想要的模式。 - **公布的目标集是静态的,而有效模式是按会话的**(schema 是注册表全局的)——已处于最宽模式的会话仍被提供这些字段。构造上无害:执行时的严格放宽检查(而非枚举)是安全边界——非放宽请求以自身文本失败且不提示任何人。 - **授权的升级不等于可工作的沙箱。** 不可用的后端即使对授权升级到约束模式也仍然失败关闭——平台没有链或所有探测失败时在 `confine()` 阶段失败,所选可执行文件无法启动时通过 spawn 通道失败,已启动的 runner 拒绝时则通过结构化规则失败——而授权的 `danger-full-access` 运行根本不触及提供方:此时授权(而非探测)是权威。 @@ -187,7 +187,7 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层 - **一个命令返回了 `[sandbox: file access denied under read-only mode]`——它失败了吗?** 它运行了,内核拒绝了一个文件操作:拒绝是与退出码正交的结果事实。相关指令禁止通过绕过限制来重试;唯一被认可的动作是以升级请求重试同一命令一次。 - **如何区分损坏的沙箱与失败的命令?** 提供方 argv 的任何 spawn 拒绝都能证明受限启动从未开始,但只有在调用方拥有的 workdir 可用,且 Node 为该 argv[0] 报告可归因的 `ENOENT` 或 `EACCES` 时,才能据此判定 runner 损坏。没有精确错误路径的裸 `syscall: 'spawn'` 和其他所有拒绝仍是普通的命令启动错误。进程启动后,只有当 `runnerFailureRules` 中某一条目同时匹配其可选退出码门控,以及排除整行精确信息性行后的一行致命 stderr 诊断时,runner 失败才会优先于拒绝。前台失败会抛出结构化的 `SANDBOX_UNAVAILABLE`,并附带 spawn 错误或匹配行作为详细信息;遭异步拒绝或已结算的后台任务则盖章 `sandbox.runnerFailed` 并渲染自己的标记。如果 `SubprocessService` 同步抛出同样带有来源信息的 `ENOENT`/`EACCES` 形态,后台启动会抛出该结构化错误;其他同步错误原样传播。Landlock 部分强制执行通知加上普通子进程失败时,仍返回命令结果。 - **在没有后端的平台上会发生什么——今天的 Windows?** `confine()` 抛出失败关闭的 `SANDBOX_UNAVAILABLE`,命令永不 spawn;`win32` 是保留的空链,由测试固定为同样失败关闭,直到 Windows runner 填充它(§ 延迟阶段)。 -- **`bwrap` 已安装在我的主机上但不可用(禁用了非特权 userns、LSM 拒绝 `mount`)——会发生什么?** 链探测是功能性的——它构建并强制一个真实 profile 而非检查 `--version`——因此存在但不可用的 `bwrap` 探测失败,选择落到注册表安装的 Landlock launcher,结论在提供方生命周期内缓存。 +- **`bwrap` 已安装在我的主机上但不可用(禁用了非特权 userns、LSM 拒绝 `mount`)——会发生什么?** 链探测是功能性的——它构建并强制一个真实 profile 而非检查 `--version`——因此存在但不可用的 `bwrap` 探测失败,选择落到已打包的 Landlock launcher,结论在提供方生命周期内缓存。 - **沙箱限制网络或进程可见性吗?** 不——`SandboxMode` 仅声称文件操作;bwrap profile 刻意不 unshare pid,没有后端声称网络。网络限制是否成为自己的旋钮留在 § seam 中开放。 - **哪些工具实际在约束下运行?** 通过 `ctx.bash` 的 OS 子进程——bash 工具及传递性的钩子命令——再加上通过沙箱化 `ctx.fs` 提供方运行的文件系统工具(`read`/`write`/`edit`,见[跨工具族 fs 沙箱 Agent Note](2026-07-14-cross-family-fs-sandbox.md)):bash 通过 OS runner 约束,fs 通过进程内路径围栏约束,二者都以同一个 `ctx.sandboxPolicy` 模式为键。web/todo 仍在进程内且不受限制(web 的唯一效果是网络,不在文件效果模式词汇内)。 - **授权的升级会持久化吗?** 不会。授权由发起请求的确切前台或后台调用消费;每个相邻调用保留自己的有效模式。后续的后台拒绝通过 `task_output` 呈现,并且可以作为一次新的精确命令重试的依据。 diff --git a/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.i18n.yaml b/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.i18n.yaml new file mode 100644 index 0000000000..3ce0e0d5e1 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.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-06-in-repository-landlock-release.md +2026-08-06-in-repository-landlock-release.md: f682078250adde8d56a4270e9d01ce4b1cd1bee9 +2026-08-06-in-repository-landlock-release.zh.md: 4950d80d87afd18c5605f4f5bca56b8d85564fc2 diff --git a/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.md b/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.md new file mode 100644 index 0000000000..f682078250 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.md @@ -0,0 +1,42 @@ +# Agent Note: In-repository Landlock release + +Status: implemented + +English | [中文](2026-08-06-in-repository-landlock-release.zh.md) + +## Problem + +The `node-addon-landlock-run` source already lives beside its DeepSeek Harness consumers under `native/landlock-run`, but it previously kept a separate pnpm workspace and lockfile and depended on a standalone repository for npm publication. Harness packages consumed a fixed registry version, so one pull request could change the launcher contract and its consumer without testing those changes together. The source repository's native workflow could rehearse the package, but it did not publish the artifact it tested. + +The mirror also duplicated release coordination: export the source, update another lockfile, run another release workflow, publish the native family, then return to this repository to bump registry dependencies. That split made source-to-binary provenance, rollback, and security-fix coordination harder without changing what npm users actually needed. + +The consolidation must preserve platform selection. The public distribution is deliberately one JavaScript entry package plus separate Linux x64 and arm64 binary packages; merging repository ownership does not imply putting every binary into one tarball or publishing every DeepSeek Harness package at the launcher version. + +## Decision + +`native/landlock-run` and `native/landlock-run/packages/*` belong to the repository's root pnpm workspace and use the root `pnpm-lock.yaml`. Harness consumers declare `node-addon-landlock-run` with `workspace:*`, so development, type checking, builds, and pull-request tests resolve the entry package from the same checkout. The root TypeScript project graph builds that entry package before consumers, and the repository cleaner owns its direct `lib/` output. + +The public npm boundary remains three packages with one launcher-family version: `node-addon-landlock-run`, `node-addon-landlock-run-linux-x64`, and `node-addon-landlock-run-linux-arm64`. The entry package retains both platform packages as `optionalDependencies`; their `os` and `cpu` manifest fields let npm install only the compatible package. Repository constraints allow public publication only for those three names, require `publishConfig.access: public`, and require their versions to match the private launcher workspace root. Other repository workspaces remain private under the existing constraint. + +The main repository owns both native CI and publication. `Landlock Run` runs for relevant pull requests and `master` pushes and builds each platform on its matching native runner. The manually dispatched `Landlock Run Release` workflow builds both platform binaries, transfers them as workflow artifacts, assembles and verifies the complete package family, packs immutable npm tarballs, installs and exercises those tarballs, and only then permits the protected publish job. Platform tarballs publish before the entry tarball that optionally depends on them. Publication uses `landlock-run-vX.Y.Z` tags so launcher releases cannot collide with other release families in the monorepo; prereleases use the npm `next` dist-tag. + +The sandbox packed-install rehearsal no longer permits the npm registry to supply the launcher. It packs the current checkout's entry and matching native package alongside the harness dependency closure, installs those local tarballs into an external plain-Node consumer, and proves that the installed launcher is executable, byte-identical to the native build, and the correct ELF architecture before testing confinement or fail-closed behavior. + +## Alternatives considered + +- **Keep the standalone repository as a release mirror** — rejected because it preserves the split lockfiles, source export, stale-registry test window, and cross-repository release sequence after the source of record has already moved here. +- **Publish one npm package containing every platform binary** — rejected because users would download binaries they cannot run and npm could no longer use package-level `os`/`cpu` filtering. Repository ownership and npm package layout are separate choices. +- **Give the launcher the root DeepSeek Harness version and publish the complete monorepo recursively** — rejected because this change owns one three-package public family, not the independent `@deepseek-ai/*` baseline. The [artifact-first npm baseline proposal](../../proposed/process/2026-08-04-artifact-first-npm-baseline-publication.md) explicitly keeps native workspaces outside its target set. +- **Cross-compile both binaries in one release job** — rejected because the checked-in package matrix already assigns each architecture a native GitHub runner and avoids adding a cross-toolchain trust surface. + +## Consequences + +Launcher protocol, TypeScript entry code, native source, harness consumption, and publish-path tests can change in one pull request and resolve from one lockfile. A release tag now identifies the source, consumer integration, build instructions, and tarballs tested by the main repository. The standalone mirror is no longer part of the release path and can be archived after the first successful in-repository publication. + +npm consumers keep the same install command and package names. A supported Linux host downloads the entry package and its matching architecture package; the other architecture package is skipped. An unsupported host receives no platform binary and follows the existing deterministic fail-closed probe path. + +The implementation touches more files than a dependency-line edit because the repository must also own workspace constraints, TypeScript build order, cleanup, CI triggers, release tags, lockfile generation, packed-install provenance, release documentation, and generated notices. The behavioral boundary stays narrow: it changes only the Landlock package family and its three direct workspace consumers, not the version or publication state of other DeepSeek Harness packages. + +The main repository's `npm-publish` environment must authorize npm trusted publishing or provide `NPM_TOKEN`; moving workflow code cannot configure those external settings. npm still publishes packages sequentially and offers no cross-package transaction, so a failed publish can leave a partial version. Because npm rejects an already-published name and version, an operator must inspect the registry and publish only the missing tarballs rather than rerunning the workflow unchanged. Linux x64 and arm64 runners remain the authoritative binary and real-kernel checks; a macOS checkout can verify the entry package and unsupported-platform behavior but cannot replace those jobs. + +This note supersedes only the release-mirror and registry-pinned source-development statements in the [sandbox Agent Note](../feature/2026-07-06-sandbox.md); that note continues to own sandbox behavior, runner selection, and enforcement semantics. diff --git a/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.zh.md b/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.zh.md new file mode 100644 index 0000000000..4950d80d87 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.zh.md @@ -0,0 +1,42 @@ +# Agent Note: 仓库内 Landlock 发布 + +Status: implemented + +[English](2026-08-06-in-repository-landlock-release.md) | 中文 + +## 问题 + +`node-addon-landlock-run` 源码已经与其 DeepSeek Harness 消费方一同位于 `native/landlock-run` 下,但此前仍保留独立的 pnpm workspace 和锁文件,并依赖一个独立仓库发布到 npm。Harness 包使用 npm 注册表中的固定版本,因此同一个 PR(Pull Request)可以同时修改启动器契约及其消费方,却无法一起测试这些改动。源码仓库的原生工作流可以演练打包流程,但不会发布它实际测试过的产物。 + +发布镜像还造成重复的发布协调工作:导出源码、更新另一份锁文件、运行另一套发布工作流、发布原生包家族,然后回到本仓库更新注册表依赖。npm 用户的实际需求并未改变,这种拆分却增加了从源码到二进制的溯源、回滚和安全修复协调难度。 + +此次整合必须保留平台选择机制。公开分发有意采用一个 JavaScript 入口包,并为 Linux x64 和 arm64 分别提供二进制包;合并仓库归属并不意味着要把所有二进制文件放进同一个 tarball,也不意味着要按照启动器版本发布所有 DeepSeek Harness 包。 + +## 决策 + +`native/landlock-run` 和 `native/landlock-run/packages/*` 属于仓库根 pnpm workspace,并使用根 `pnpm-lock.yaml`。Harness 消费方将 `node-addon-landlock-run` 声明为 `workspace:*`,因此开发、类型检查、构建和 PR 测试都会从同一个 checkout 解析入口包。根 TypeScript 项目图会先构建该入口包,再构建消费方;仓库清理器负责清理其直接生成的 `lib/` 输出目录。 + +公开 npm 分发边界仍由 3 个包组成,它们共用一个启动器包家族版本:`node-addon-landlock-run`、`node-addon-landlock-run-linux-x64` 和 `node-addon-landlock-run-linux-arm64`。入口包继续通过 `optionalDependencies` 声明两个平台包;它们在 manifest(元数据清单)中的 `os` 和 `cpu` 字段让 npm 只安装兼容的包。仓库约束只允许公开发布这 3 个包名,要求设置 `publishConfig.access: public`,并要求其版本与私有启动器 workspace 根包一致。仓库中的其他 workspace 仍受现有约束保护,保持私有状态。 + +主仓库同时负责原生 CI 和发布。`Landlock Run` 会为相关 PR 和 `master` 推送运行,并在各自匹配的原生 runner 上构建每个平台包。手动触发的 `Landlock Run Release` 工作流会构建两个平台的二进制文件,将其作为工作流产物传递,组装并验证完整的包家族,打包出内容不可变的 npm tarball,安装并实际运行这些 tarball,之后才允许受保护的发布作业执行。发布顺序是平台 tarball 在前,最后发布将它们列为可选依赖的入口 tarball。发布使用 `landlock-run-vX.Y.Z` tag,避免启动器版本与 monorepo 中其他发布家族发生冲突;预发布版本使用 npm 的 `next` dist-tag。 + +沙箱打包安装演练不再允许 npm 注册表提供启动器。它会将当前 checkout 的入口包、匹配的原生包和 harness 依赖闭包一起打包,把这些本地 tarball 安装到仓库外部的纯 Node 消费方中,并在测试约束效果或失败闭合行为之前,证明所安装的启动器可执行、与原生构建产物字节完全一致,且具有正确的 ELF 架构。 + +## 曾考虑的替代方案 + +- **保留独立仓库作为发布镜像**:不予采纳,因为在权威源码已经迁入本仓库后,这仍会保留拆分的锁文件、源码导出、测试使用陈旧注册表版本的时间窗,以及跨仓库发布序列。 +- **发布一个包含所有平台二进制文件的 npm 包**:不予采纳,因为用户会下载无法在其主机上运行的二进制文件,而且 npm 无法再利用包级 `os`/`cpu` 筛选。仓库归属与 npm 包布局是两个彼此独立的选择。 +- **让启动器使用 DeepSeek Harness 根版本,并递归发布整个 monorepo**:不予采纳,因为本次改动负责的是一个由 3 个包组成的公开包家族,而不是独立的 `@deepseek-ai/*` 基线。[产物优先的 npm 基线提案](../../proposed/process/2026-08-04-artifact-first-npm-baseline-publication.md)明确将原生 workspace 排除在其目标集合之外。 +- **在一个发布作业中交叉编译两个二进制文件**:不予采纳,因为仓库内已提交的包矩阵已经为每种架构分配了原生 GitHub runner,无需再把交叉工具链纳入信任边界。 + +## 后果 + +同一个 PR 可以同时修改启动器协议、TypeScript 入口代码、原生源码、harness 消费方式和发布路径测试,并从同一份锁文件解析这些内容。发布 tag 现在标识源码、消费方集成、构建指令,以及主仓库测试过的 tarball。第一次成功从本仓库发布后,独立镜像便不再属于发布路径,可以归档。 + +npm 消费方继续使用相同的安装命令和包名。受支持的 Linux 主机会下载入口包及与其架构匹配的包,并跳过另一架构的包。不受支持的主机不会收到平台二进制文件,并继续沿用现有的确定性失败闭合探测路径。 + +实现涉及的文件比只修改一行依赖更多,因为仓库还必须负责 workspace 约束、TypeScript 构建顺序、清理、CI 触发条件、发布 tag、锁文件生成、打包安装来源证明、发布文档和生成的第三方声明。行为边界仍然很窄:此次改动只影响 Landlock 包家族及其 3 个直接 workspace 消费方,不改变其他 DeepSeek Harness 包的版本或发布状态。 + +主仓库的 `npm-publish` 环境必须授权 npm trusted publishing,或提供 `NPM_TOKEN`;只迁移工作流代码无法配置这些外部设置。npm 仍会按顺序发布各个包,且不提供跨包事务,因此发布失败可能留下只完成了一部分的版本。由于 npm 会拒绝已经发布的同名同版本包,操作人员必须检查注册表并只发布缺失的 tarball,而不能原样重新运行工作流。Linux x64 和 arm64 runner 仍提供权威的二进制构建与真实内核检查;macOS checkout 可以验证入口包和不受支持平台上的行为,但不能取代这些作业。 + +本说明仅取代[沙箱 Agent Note](../feature/2026-07-06-sandbox.md)中有关发布镜像和开发源码时依赖注册表固定版本的表述;该 Agent Note 仍负责沙箱行为、runner 选择和强制执行语义。 diff --git a/.github/workflows/landlock-run-release.yml b/.github/workflows/landlock-run-release.yml new file mode 100644 index 0000000000..dca6c9eed1 --- /dev/null +++ b/.github/workflows/landlock-run-release.yml @@ -0,0 +1,170 @@ +# Build and publish the node-addon-landlock-run package family from the +# harness source of record. Rehearsal and publication consume the same packed +# tarballs; each native binary is built on its matching architecture. +name: Landlock Run Release + +on: + workflow_dispatch: + inputs: + publish: + description: Publish packed tarballs to npm. Must run from a landlock-run-v* tag. + required: true + type: boolean + default: false + +permissions: + contents: read + +concurrency: + # Stable/prerelease dist-tags are shared registry state; serialize release + # runs so two versions cannot race the final tag assignment. + group: ${{ github.workflow }} + cancel-in-progress: false + +defaults: + run: + working-directory: native/landlock-run + +jobs: + matrix: + name: Matrix + runs-on: ubuntu-24.04 + outputs: + prebuilds: ${{ steps.matrix.outputs.prebuilds }} + steps: + - uses: actions/checkout@v4 + + - id: matrix + run: echo "prebuilds=$(node ./scripts/github-matrix.mjs release-prebuild)" >> "$GITHUB_OUTPUT" + + build-prebuilds: + name: ${{ matrix.package }} + needs: matrix + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.matrix.outputs.prebuilds) }} + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + package_json_file: package.json + + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - name: Install dependencies + run: pnpm install --filter node-addon-landlock-run-workspace... --frozen-lockfile + + - name: Install musl toolchain + run: | + sudo apt-get update -q + sudo apt-get install -yq musl-tools + + - name: Build native binaries + run: pnpm build:native + + - name: Verify binary metadata + run: node ./scripts/verify-launcher-binary.mjs ${{ matrix.dir }} + + - name: Upload prebuild artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact }} + path: native/landlock-run/${{ matrix.dir }}/bin/* + if-no-files-found: error + retention-days: 7 + + pack: + name: Pack npm tarballs + needs: build-prebuilds + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + package_json_file: package.json + + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - name: Install dependencies + run: pnpm install --filter node-addon-landlock-run-workspace... --frozen-lockfile + + - name: Build TypeScript + run: pnpm build:ts + + - name: Verify release version + run: node ./scripts/verify-release.mjs + env: + RELEASE_PUBLISH: ${{ inputs.publish }} + + - name: Download prebuild artifacts + uses: actions/download-artifact@v4 + with: + pattern: prebuild-* + path: native/landlock-run/.release/prebuild-artifacts + + - name: Assemble and verify prebuilds + run: node ./scripts/assemble-prebuilds.mjs .release/prebuild-artifacts + + - name: Verify release payload + run: node ./scripts/verify-release.mjs --prebuilds + env: + RELEASE_PUBLISH: ${{ inputs.publish }} + + - name: Pack release tarballs + run: node ./scripts/pack-release.mjs dist/npm + + - name: Verify packed install + run: node ./scripts/verify-packed-install.mjs dist/npm + env: + NALR_REQUIRE_LANDLOCK: 1 + + - name: Upload npm tarballs + uses: actions/upload-artifact@v4 + with: + name: npm-tarballs + path: native/landlock-run/dist/npm/* + if-no-files-found: error + retention-days: 7 + + publish: + name: Publish to npm + if: inputs.publish + needs: pack + runs-on: ubuntu-24.04 + environment: npm-publish + permissions: + contents: read + id-token: write + steps: + - uses: actions/setup-node@v4 + with: + node-version: 24 + registry-url: https://registry.npmjs.org + + - name: Download npm tarballs + uses: actions/download-artifact@v4 + with: + name: npm-tarballs + path: native/landlock-run/dist/npm + + - name: Publish tarballs + run: | + version="${GITHUB_REF#refs/tags/landlock-run-v}" + tag_args=() + case "$version" in *-*) tag_args=(--tag next);; esac + while IFS= read -r tarball; do + npm publish "dist/npm/${tarball}" --access public "${tag_args[@]}" + done < dist/npm/publish-order.txt + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/landlock-run.yml b/.github/workflows/landlock-run.yml index dad9638761..391e1eeae1 100644 --- a/.github/workflows/landlock-run.yml +++ b/.github/workflows/landlock-run.yml @@ -1,15 +1,27 @@ -# Manually-dispatched CI for the landlock-run source of record -# (native/landlock-run). A separate workflow from ci.yml on purpose: the -# subtree is a self-contained pnpm workspace with its own gates, exercised on -# demand — per-architecture native legs (build + behavioral tests + pack -# rehearsal on real kernels) plus one darwin leg proving the documented -# degradation on hosts without a platform package. Legs derive from the -# subtree's checked-in package matrix (scripts/github-matrix.mjs). Packing -# for npm happens in the release mirror (node-addon-landlock-run) after an -# export — see native/README.md; this workflow never packs for release. +# CI for the landlock-run packages under native/landlock-run. A separate +# workflow from ci.yml keeps the native OS/architecture matrix independent of +# the harness Node matrix. Release assembly and publication use the companion +# Landlock Run Release workflow. name: Landlock Run on: + pull_request: + paths: + - '.github/workflows/landlock-run.yml' + - '.github/workflows/landlock-run-release.yml' + - 'native/landlock-run/**' + - 'package.json' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + push: + branches: [master] + paths: + - '.github/workflows/landlock-run.yml' + - '.github/workflows/landlock-run-release.yml' + - 'native/landlock-run/**' + - 'package.json' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' workflow_dispatch: concurrency: @@ -52,16 +64,16 @@ jobs: - uses: pnpm/action-setup@v4 with: - package_json_file: native/landlock-run/package.json + package_json_file: package.json - uses: actions/setup-node@v4 with: node-version: 24 cache: pnpm - cache-dependency-path: native/landlock-run/pnpm-lock.yaml + cache-dependency-path: pnpm-lock.yaml - name: Install dependencies - run: pnpm install --frozen-lockfile + run: pnpm install --filter node-addon-landlock-run-workspace... --frozen-lockfile - name: Install musl toolchain run: | @@ -103,16 +115,16 @@ jobs: - uses: pnpm/action-setup@v4 with: - package_json_file: native/landlock-run/package.json + package_json_file: package.json - uses: actions/setup-node@v4 with: node-version: 24 cache: pnpm - cache-dependency-path: native/landlock-run/pnpm-lock.yaml + cache-dependency-path: pnpm-lock.yaml - name: Install dependencies - run: pnpm install --frozen-lockfile + run: pnpm install --filter node-addon-landlock-run-workspace... --frozen-lockfile - name: Build TypeScript run: pnpm build:ts diff --git a/.github/workflows/sandbox.yml b/.github/workflows/sandbox.yml index 939ca2f6ab..2dfaf0e175 100644 --- a/.github/workflows/sandbox.yml +++ b/.github/workflows/sandbox.yml @@ -3,9 +3,8 @@ # .agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md. # A separate workflow from ci.yml because the axis is different — these jobs # fan out over OS×runner (kernel capabilities), not node versions. The Landlock -# launcher arrives from the registry with `pnpm install` (the npm package family -# `node-addon-landlock-run`, built and released from its own repository), so -# these legs exercise the true consumer path — nothing is compiled here. +# launcher is built from native/landlock-run on each Landlock leg and installed +# from the same tarballs the main-repository release workflow publishes. name: Sandbox on: @@ -30,7 +29,7 @@ jobs: # an OS×runner matrix — bwrap and Landlock on Linux (separate legs: the # Landlock files force the bwrap rung off, so each leg proves exactly one # rung; Landlock twice, once per architecture, each confining through the - # registry-installed launcher), Seatbelt on macOS (sandbox-exec ships with + # locally built launcher), Seatbelt on macOS (sandbox-exec ships with # the OS). One node # version only: kernel confinement does not vary by node, and ci.yml's # node matrix already covers the node axis. @@ -80,6 +79,14 @@ jobs: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 \ || echo "apparmor userns knob absent — the functional probe decides" + - name: Build Landlock launcher for this architecture + if: matrix.runner == 'landlock' + run: | + sudo apt-get update -q + sudo apt-get install -yq musl-tools + pnpm --dir native/landlock-run run build:ts + pnpm --dir native/landlock-run run build:native + # The unit suite runs on ubuntu in `checks`; this is the one darwin leg # in the workflow, so run it here too — the platform-dependent unit # expectations (Seatbelt path canonicalization: /tmp IS /private/tmp) @@ -105,14 +112,10 @@ jobs: # the very platform that exists to prove it) is a failure, not a pass. echo "$out" | grep -qE 'Test Files[[:space:]]+2 passed \(2\)' - # Publish-path rehearsal, Landlock legs only (the pack gates need built - # lib/). The e2e packs the workspace closure, installs the tarballs - # into a throwaway consumer — npm pulling `node-addon-landlock-run` - # and its platform package from the registry, the true consumer path — - # and confines through the INSTALLED launcher, asserting it executable - # apart (a mode-stripped binary must not masquerade as a non-enforcing - # kernel). Same no-silent-skip guard as above. - - name: Build packages (lib/ for the pack rehearsal) + # Publish-path rehearsal, Landlock legs only. Build the launcher on its + # native architecture, then install the local native and harness tarballs + # together so registry state cannot mask source/package drift. + - name: Build packages for the pack rehearsal if: matrix.runner == 'landlock' run: pnpm run build diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 7d2e257ea9..aa73dba8d9 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -170,6 +170,6 @@ Direct dependencies of the `pyproject.toml` manifests, plus `uv` as the developm | --- | --- | --- | | [`@yao-pkg/pkg`](https://github.com/yao-pkg/pkg) | MIT | invoked by `scripts/build-exe-for-python-sdk.ts` to assemble the single-file SDK runtime executable | -## First-party sibling releases +## First-party native packages -`node-addon-landlock-run` (and its platform packages) is released from a DeepSeek Harness sibling repository under BSD 3-Clause. It is listed here for completeness; it is first-party, not third-party. +`node-addon-landlock-run` (and its platform packages) is built and released from this repository under BSD 3-Clause. It is listed here for completeness; it is first-party, not third-party. diff --git a/knip.json b/knip.json index dfb8058d7c..60853c6517 100644 --- a/knip.json +++ b/knip.json @@ -5,6 +5,7 @@ ], "ignoreBinaries": [ "bwrap", + "musl-gcc", "python3", "sandbox-exec", "taskkill" diff --git a/native/README.i18n.yaml b/native/README.i18n.yaml index a55273be29..31a86ebe73 100644 --- a/native/README.i18n.yaml +++ b/native/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 native/README.md -README.md: a79d9ca5747d4c4fbfa50745b3eece07b96aea58 -README.zh.md: 708430c0a087e5a9da1dae6fd078cb477db65d9b +README.md: 51c8da7b57df15e65b8e431ee18ce6cebb89d54b +README.zh.md: baf530157c6731893a66aea301c6dd962592defc diff --git a/native/README.md b/native/README.md index a79d9ca574..51c8da7b57 100644 --- a/native/README.md +++ b/native/README.md @@ -2,12 +2,10 @@ English | [中文](README.zh.md) -Source of record for `node-addon-landlock-run`, the Landlock self-restrict-then-exec launcher consumed by the harness. The [`landlock-run/` workspace](landlock-run/README.md) owns its architecture, package family, platform support, development workflow, and release procedure. The standalone repository is a release mirror. +Native source and public packages maintained with DeepSeek Harness. The [`landlock-run/` workspace](landlock-run/README.md) owns the Landlock self-restrict-then-exec launcher consumed by the harness, including its architecture, three-package npm family, platform support, development workflow, and [release procedure](landlock-run/docs/release.md). -## Release mirror +## Workspace and release boundary -| Directory | Mirror repo | Last exported release | Commit | -|---|---|---|---| -| `landlock-run/` | https://github.com/deepseek-harness/node-addon-landlock-run | `v0.0.1` | `614f7fd7dc11e6eaceefba9e7ff1fbe28b51ba22` | +`landlock-run/` and its packages belong to the repository's root pnpm workspace and lockfile. Harness consumers use the current workspace entry package during development and CI, so a launcher contract change and its consumer update can land and be tested together. -The subtree is a self-contained pnpm workspace and is not part of the harness workspace. The [launcher release reference](landlock-run/docs/release.md) owns the export and publication workflow. The mirror must not diverge: port any direct mirror hotfix back here before the next export. +The main repository's `Landlock Run` workflow builds and tests each supported architecture. `Landlock Run Release` assembles those native artifacts, packs and verifies the three npm tarballs, then optionally publishes them under one launcher version. The entry package retains platform packages as npm optional dependencies, so npm still installs only the package matching the user's operating system and CPU. diff --git a/native/README.zh.md b/native/README.zh.md index 708430c0a0..baf530157c 100644 --- a/native/README.zh.md +++ b/native/README.zh.md @@ -2,12 +2,10 @@ [English](README.md) | 中文 -`node-addon-landlock-run` 的真源;它是供 harness 使用、先施加 Landlock 自限再执行命令的启动器。[`landlock-run/` workspace](landlock-run/README.md)负责其架构、包家族、平台支持、开发工作流和发布流程。独立仓库是发布镜像。 +与 DeepSeek Harness 一同维护的原生源码和公开包。[`landlock-run/` workspace](landlock-run/README.md)负责 harness 使用的 Landlock 自限后执行启动器,包括其架构、由三个包组成的 npm 包家族、平台支持、开发工作流和[发布流程](landlock-run/docs/release.md)。 -## 发布镜像 +## Workspace 与发布边界 -| 目录 | 镜像仓库 | 最近导出的版本 | Commit | -|---|---|---|---| -| `landlock-run/` | https://github.com/deepseek-harness/node-addon-landlock-run | `v0.0.1` | `614f7fd7dc11e6eaceefba9e7ff1fbe28b51ba22` | +`landlock-run/` 及其包属于仓库根 pnpm workspace,并共用根锁文件。开发和 CI 中的 harness 消费方直接使用当前 workspace 的入口包,因此启动器契约变更与消费方更新可以在同一个改动中落地并一起测试。 -该子树是自包含的 pnpm workspace,不属于 harness workspace。[启动器发布参考](landlock-run/docs/release.md)负责导出和发布工作流。镜像不得发生分歧:下次导出前,必须把任何直接施加于镜像的热修复移植回此处。 +主仓库的 `Landlock Run` 工作流为每个受支持架构构建并测试。`Landlock Run Release` 汇集这些原生产物,打包并验证三个 npm tarball,随后可选择以同一个启动器版本发布。入口包继续将平台包声明为 npm 可选依赖,因此 npm 仍然只会安装与用户操作系统和 CPU 匹配的包。 diff --git a/native/landlock-run/AGENTS.md b/native/landlock-run/AGENTS.md index 31e12e177c..6742ff81d4 100644 --- a/native/landlock-run/AGENTS.md +++ b/native/landlock-run/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md -This workspace builds `landlock-run`, a Landlock self-restrict-then-exec launcher: a small, auditable confinement binary distributed as prebuilt per-platform npm packages, plus the thin JS entry package that resolves it and speaks its CLI contract. The source of record is the `deepseek-harness` repository's `native/landlock-run/`; the `node-addon-landlock-run` repository is the release mirror this tree is exported to for packing and publishing (procedure: `native/README.md` in the harness repo). Make changes in the source of record, never only in the mirror. +This directory builds `landlock-run`, a Landlock self-restrict-then-exec launcher: a small, auditable confinement binary distributed as prebuilt per-platform npm packages, plus the thin JS entry package that resolves it and speaks its CLI contract. It belongs to the repository's root pnpm workspace and lockfile. The main repository owns native CI, tarball assembly, verification, and npm publication; keep package-family changes coordinated with harness consumers in the same repository. ## Pre-release stance diff --git a/native/landlock-run/docs/release.md b/native/landlock-run/docs/release.md index e1ea65c411..a95cffd47f 100644 --- a/native/landlock-run/docs/release.md +++ b/native/landlock-run/docs/release.md @@ -4,45 +4,45 @@ Pre-1.0: treat this as a release checklist, not a stability policy. ## Versioning -One version across every package in the repo. Use the bump helper: +The launcher workspace root and its three public packages share one version. Run the bump helper from the repository root: ```sh -pnpm release:bump patch # or minor / major / x.y.z +pnpm --dir native/landlock-run release:bump patch # or minor / major / x.y.z ``` -It updates the root and every `packages/*` manifest, refreshes the lockfile (`--ignore-scripts --lockfile-only`), and runs `release:verify`. Explicit versions accept full semver including prereleases (`pnpm release:bump 0.0.0-test.0`); the publish workflow puts prerelease versions under the `next` dist-tag, so `latest` never points at a test build. Keep `workspace:*` dependencies in source; pnpm converts them to concrete versions during pack. +It updates `native/landlock-run/package.json` and every `native/landlock-run/packages/*` manifest, refreshes the repository root lockfile (`--ignore-scripts --lockfile-only`), and runs `release:verify`. Explicit versions accept full semver including prereleases (`pnpm --dir native/landlock-run release:bump 0.0.0-test.0`); the publish workflow puts prerelease versions under the `next` dist-tag, so `latest` never points at a test build. Keep `workspace:*` dependencies in source; pnpm converts them to concrete versions during pack. -Version bumps are normal source changes: open a release PR (or commit) with the manifests and lockfile, merge it, then create the matching `vX.Y.Z` tag from that commit. The publish workflow validates that the tag matches every package version. +Version bumps are normal source changes: open a release PR (or commit) with the launcher manifests and root lockfile, merge it, then create the matching `landlock-run-vX.Y.Z` tag from that commit. The namespace avoids colliding with release tags for other package families in the repository. The publish workflow validates that the tag matches every launcher package version. ```sh -pnpm release:commit patch # bump + stage + commit in one command -git tag v0.0.2 +pnpm --dir native/landlock-run release:commit patch # bump + stage + commit in one command +git tag landlock-run-v0.0.2 ``` ## Preflight ```sh pnpm install --frozen-lockfile -pnpm build:ts -pnpm typecheck -pnpm test:entry +pnpm --dir native/landlock-run build:ts +pnpm --dir native/landlock-run typecheck +pnpm --dir native/landlock-run test:entry ``` On a Linux host, also rehearse the pack path locally: ```sh -pnpm build:native -pnpm test:launcher -node ./scripts/pack-release.mjs .release/npm --current-platform-only -node ./scripts/verify-packed-install.mjs .release/npm --current-platform-only +pnpm --dir native/landlock-run build:native +pnpm --dir native/landlock-run test:launcher +node native/landlock-run/scripts/pack-release.mjs native/landlock-run/.release/npm --current-platform-only +node native/landlock-run/scripts/verify-packed-install.mjs native/landlock-run/.release/npm --current-platform-only ``` ## Publish -Use the `Release` workflow so every binary is built on its matching native runner: +Use the main repository's `Landlock Run Release` workflow so every binary is built on its matching native runner: 1. Run it with `publish=false` (from the release commit) to build all platform binaries, assemble and verify the payloads, pack the tarballs in publish order, rehearse the packed install, and upload the `npm-tarballs` artifact for inspection. -2. Create and push the `vX.Y.Z` tag matching the package versions. +2. Create and push the `landlock-run-vX.Y.Z` tag matching the package versions. 3. Run the same workflow from that tag with `publish=true`. The workflow publishes only from the final packed tarballs, in `publish-order.txt` order (platform packages before the entry that optionally depends on them). It supports npm trusted publishing through GitHub OIDC; without it, provide an `NPM_TOKEN` secret in the `npm-publish` environment. Packages publish with `--access public`. @@ -50,9 +50,9 @@ The workflow publishes only from the final packed tarballs, in `publish-order.tx Manual local fallback (current platform's packages only) — always through `pack-release.mjs`, never `pnpm publish` directly (pnpm's pack path strips the launcher's executable bit; see [packaging.md](packaging.md)): ```sh -node ./scripts/pack-release.mjs dist/npm --current-platform-only -node ./scripts/verify-packed-install.mjs dist/npm --current-platform-only -while IFS= read -r tarball; do npm publish "dist/npm/${tarball}" --access public; done < dist/npm/publish-order.txt +node native/landlock-run/scripts/pack-release.mjs native/landlock-run/dist/npm --current-platform-only +node native/landlock-run/scripts/verify-packed-install.mjs native/landlock-run/dist/npm --current-platform-only +while IFS= read -r tarball; do npm publish "native/landlock-run/dist/npm/${tarball}" --access public; done < native/landlock-run/dist/npm/publish-order.txt ``` Do not commit `.npmrc` files with tokens or registry overrides. diff --git a/native/landlock-run/pnpm-lock.yaml b/native/landlock-run/pnpm-lock.yaml deleted file mode 100644 index dd309fb046..0000000000 --- a/native/landlock-run/pnpm-lock.yaml +++ /dev/null @@ -1,345 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - devDependencies: - '@types/node': - specifier: ^26.0.1 - version: 26.0.1 - node-addon-landlock-run: - specifier: workspace:* - version: link:packages/entry - tsx: - specifier: ^4.20.6 - version: 4.23.0 - typescript: - specifier: ^6.0.3 - version: 6.0.3 - - packages/entry: - optionalDependencies: - node-addon-landlock-run-linux-arm64: - specifier: workspace:* - version: link:../linux-arm64 - node-addon-landlock-run-linux-x64: - specifier: workspace:* - version: link:../linux-x64 - - packages/linux-arm64: {} - - packages/linux-x64: {} - -packages: - - '@esbuild/aix-ppc64@0.28.1': - resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - - '@esbuild/android-arm64@0.28.1': - resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm@0.28.1': - resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - - '@esbuild/android-x64@0.28.1': - resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - - '@esbuild/darwin-arm64@0.28.1': - resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-x64@0.28.1': - resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - - '@esbuild/freebsd-arm64@0.28.1': - resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.28.1': - resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - - '@esbuild/linux-arm64@0.28.1': - resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm@0.28.1': - resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-ia32@0.28.1': - resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-loong64@0.28.1': - resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-mips64el@0.28.1': - resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-ppc64@0.28.1': - resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-riscv64@0.28.1': - resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-s390x@0.28.1': - resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-x64@0.28.1': - resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - - '@esbuild/netbsd-arm64@0.28.1': - resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.28.1': - resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/openbsd-arm64@0.28.1': - resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.28.1': - resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openharmony-arm64@0.28.1': - resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - - '@esbuild/sunos-x64@0.28.1': - resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - - '@esbuild/win32-arm64@0.28.1': - resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-ia32@0.28.1': - resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-x64@0.28.1': - resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - - '@types/node@26.0.1': - resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==} - - esbuild@0.28.1: - resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} - engines: {node: '>=18'} - hasBin: true - - fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - - tsx@4.23.0: - resolution: {integrity: sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==} - engines: {node: '>=18.0.0'} - hasBin: true - - typescript@6.0.3: - resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} - engines: {node: '>=14.17'} - hasBin: true - - undici-types@8.3.0: - resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} - -snapshots: - - '@esbuild/aix-ppc64@0.28.1': - optional: true - - '@esbuild/android-arm64@0.28.1': - optional: true - - '@esbuild/android-arm@0.28.1': - optional: true - - '@esbuild/android-x64@0.28.1': - optional: true - - '@esbuild/darwin-arm64@0.28.1': - optional: true - - '@esbuild/darwin-x64@0.28.1': - optional: true - - '@esbuild/freebsd-arm64@0.28.1': - optional: true - - '@esbuild/freebsd-x64@0.28.1': - optional: true - - '@esbuild/linux-arm64@0.28.1': - optional: true - - '@esbuild/linux-arm@0.28.1': - optional: true - - '@esbuild/linux-ia32@0.28.1': - optional: true - - '@esbuild/linux-loong64@0.28.1': - optional: true - - '@esbuild/linux-mips64el@0.28.1': - optional: true - - '@esbuild/linux-ppc64@0.28.1': - optional: true - - '@esbuild/linux-riscv64@0.28.1': - optional: true - - '@esbuild/linux-s390x@0.28.1': - optional: true - - '@esbuild/linux-x64@0.28.1': - optional: true - - '@esbuild/netbsd-arm64@0.28.1': - optional: true - - '@esbuild/netbsd-x64@0.28.1': - optional: true - - '@esbuild/openbsd-arm64@0.28.1': - optional: true - - '@esbuild/openbsd-x64@0.28.1': - optional: true - - '@esbuild/openharmony-arm64@0.28.1': - optional: true - - '@esbuild/sunos-x64@0.28.1': - optional: true - - '@esbuild/win32-arm64@0.28.1': - optional: true - - '@esbuild/win32-ia32@0.28.1': - optional: true - - '@esbuild/win32-x64@0.28.1': - optional: true - - '@types/node@26.0.1': - dependencies: - undici-types: 8.3.0 - - esbuild@0.28.1: - optionalDependencies: - '@esbuild/aix-ppc64': 0.28.1 - '@esbuild/android-arm': 0.28.1 - '@esbuild/android-arm64': 0.28.1 - '@esbuild/android-x64': 0.28.1 - '@esbuild/darwin-arm64': 0.28.1 - '@esbuild/darwin-x64': 0.28.1 - '@esbuild/freebsd-arm64': 0.28.1 - '@esbuild/freebsd-x64': 0.28.1 - '@esbuild/linux-arm': 0.28.1 - '@esbuild/linux-arm64': 0.28.1 - '@esbuild/linux-ia32': 0.28.1 - '@esbuild/linux-loong64': 0.28.1 - '@esbuild/linux-mips64el': 0.28.1 - '@esbuild/linux-ppc64': 0.28.1 - '@esbuild/linux-riscv64': 0.28.1 - '@esbuild/linux-s390x': 0.28.1 - '@esbuild/linux-x64': 0.28.1 - '@esbuild/netbsd-arm64': 0.28.1 - '@esbuild/netbsd-x64': 0.28.1 - '@esbuild/openbsd-arm64': 0.28.1 - '@esbuild/openbsd-x64': 0.28.1 - '@esbuild/openharmony-arm64': 0.28.1 - '@esbuild/sunos-x64': 0.28.1 - '@esbuild/win32-arm64': 0.28.1 - '@esbuild/win32-ia32': 0.28.1 - '@esbuild/win32-x64': 0.28.1 - - fsevents@2.3.3: - optional: true - - tsx@4.23.0: - dependencies: - esbuild: 0.28.1 - optionalDependencies: - fsevents: 2.3.3 - - typescript@6.0.3: {} - - undici-types@8.3.0: {} diff --git a/native/landlock-run/pnpm-workspace.yaml b/native/landlock-run/pnpm-workspace.yaml deleted file mode 100644 index 22299bfea0..0000000000 --- a/native/landlock-run/pnpm-workspace.yaml +++ /dev/null @@ -1,8 +0,0 @@ -packages: - - packages/* - -# pnpm 10+ blocks any dependency shipping an install/build script until it is -# explicitly reviewed here. Deny by default; esbuild (tsx's bundled native -# binary) genuinely needs its script. -allowBuilds: - esbuild: true diff --git a/native/landlock-run/scripts/bump-release.mjs b/native/landlock-run/scripts/bump-release.mjs index 29a7777379..55033eed09 100644 --- a/native/landlock-run/scripts/bump-release.mjs +++ b/native/landlock-run/scripts/bump-release.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node /** - * Bump every package (workspace root + packages/*) to one version, refresh - * the lockfile, and verify. Usage: `pnpm release:bump `. + * Bump the launcher workspace root and packages/* to one version, refresh the + * repository lockfile, and verify. Usage: `pnpm release:bump `. */ import fs from 'node:fs'; @@ -11,14 +11,15 @@ import { packageDirs, readJson, root } from './repo.mjs'; const bump = process.argv[2]; const releaseTypes = new Set(['major', 'minor', 'patch']); +const repositoryRoot = path.resolve(root, '../..'); function writeJson(file, value) { fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`); } -function run(command, args) { +function run(command, args, cwd = root) { const result = spawnSync(command, args, { - cwd: root, + cwd, stdio: 'inherit', env: { ...process.env, CI: 'true' }, }); @@ -84,7 +85,7 @@ for (const file of files) { console.log(`${file}: ${targetVersion}`); } -run('pnpm', ['install', '--ignore-scripts', '--lockfile-only']); +run('pnpm', ['install', '--ignore-scripts', '--lockfile-only'], repositoryRoot); run('node', ['./scripts/verify-release.mjs']); console.log(`Release version bumped to ${targetVersion}`); diff --git a/native/landlock-run/scripts/commit-release.mjs b/native/landlock-run/scripts/commit-release.mjs index b7bf3e513b..1b1c78bce5 100644 --- a/native/landlock-run/scripts/commit-release.mjs +++ b/native/landlock-run/scripts/commit-release.mjs @@ -1,8 +1,8 @@ #!/usr/bin/env node /** * Bump, stage, and commit a release in one command: - * `pnpm release:commit `. The tag stays manual — - * create it from the merged release commit. + * `pnpm release:commit `. The namespaced tag stays + * manual — create it from the merged release commit. */ import path from 'node:path'; @@ -35,8 +35,8 @@ run('git', [ 'add', 'package.json', 'packages/*/package.json', - 'pnpm-lock.yaml', + '../../pnpm-lock.yaml', ]); -run('git', ['commit', '-m', `release: ${version}`]); +run('git', ['commit', '-m', `release(landlock-run): ${version}`]); -console.log(`Committed release ${version}. Create the tag manually: git tag v${version}`); +console.log(`Committed release ${version}. Create the tag manually: git tag landlock-run-v${version}`); diff --git a/native/landlock-run/scripts/repo.mjs b/native/landlock-run/scripts/repo.mjs index 8032d3da37..db5ffaf28d 100644 --- a/native/landlock-run/scripts/repo.mjs +++ b/native/landlock-run/scripts/repo.mjs @@ -12,10 +12,10 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; export const root = fileURLToPath(new URL('..', import.meta.url)); -export const packagesRoot = path.join(root, 'packages'); +const packagesRoot = path.join(root, 'packages'); /** ELF `e_machine` (offset 18, little-endian) per platform-package `cpu` value. */ -export const E_MACHINE = { x64: 62, arm64: 183 }; +const E_MACHINE = { x64: 62, arm64: 183 }; export function readJson(file) { return JSON.parse(fs.readFileSync(file, 'utf8')); diff --git a/native/landlock-run/scripts/verify-release.mjs b/native/landlock-run/scripts/verify-release.mjs index e812b34a14..ff3917b4b5 100644 --- a/native/landlock-run/scripts/verify-release.mjs +++ b/native/landlock-run/scripts/verify-release.mjs @@ -1,8 +1,8 @@ #!/usr/bin/env node /** * Release verification. Always: every published package carries one shared - * version, and — when running from a tag or publishing — the `vX.Y.Z` tag - * matches it. With `--prebuilds`: every platform package's declared + * version, and — when running from a tag or publishing — the + * `landlock-run-vX.Y.Z` tag matches it. With `--prebuilds`: every platform package's declared * binaries exist with the right ELF architecture (run after * `assemble-prebuilds.mjs` or a local `build:native`). */ @@ -10,6 +10,8 @@ import path from 'node:path'; import { packageDirs, platformDirs, readJson, root, verifyPlatformBinaries } from './repo.mjs'; +const TAG_PREFIX = 'refs/tags/landlock-run-v'; + function verifyVersions() { const packages = packageDirs().map((dir) => ({ dir, @@ -26,13 +28,13 @@ function verifyVersions() { const version = packages[0].manifest.version; const ref = process.env.GITHUB_REF || ''; const publish = process.env.RELEASE_PUBLISH === 'true'; - if (publish && !ref.startsWith('refs/tags/v')) { - throw new Error('publishing requires running the workflow from a v* tag'); + if (publish && !ref.startsWith(TAG_PREFIX)) { + throw new Error('publishing requires running the workflow from a landlock-run-v* tag'); } - if (ref.startsWith('refs/tags/v')) { - const tagVersion = ref.slice('refs/tags/v'.length); + if (ref.startsWith(TAG_PREFIX)) { + const tagVersion = ref.slice(TAG_PREFIX.length); if (tagVersion !== version) { - throw new Error(`tag/version mismatch: tag v${tagVersion}, packages ${version}`); + throw new Error(`tag/version mismatch: tag landlock-run-v${tagVersion}, packages ${version}`); } } diff --git a/package.json b/package.json index 7bd84db93a..8340580e6d 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ "workspaces": [ "vendor/*", "packages/*/*", + "native/landlock-run", + "native/landlock-run/packages/*", "apps/*", "website" ], diff --git a/packages/bash/bash-sandbox/package.json b/packages/bash/bash-sandbox/package.json index e3d3c3deb3..6a29c3f6c8 100644 --- a/packages/bash/bash-sandbox/package.json +++ b/packages/bash/bash-sandbox/package.json @@ -41,6 +41,6 @@ "@deepseek-ai/dsh-sandbox-local": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "cordis": "^4.0.0-rc.7", - "node-addon-landlock-run": "0.0.0-test.0" + "node-addon-landlock-run": "workspace:*" } } diff --git a/packages/bash/bash-sandbox/tsconfig.json b/packages/bash/bash-sandbox/tsconfig.json index fcd79e0296..7a7d67f0cb 100644 --- a/packages/bash/bash-sandbox/tsconfig.json +++ b/packages/bash/bash-sandbox/tsconfig.json @@ -14,6 +14,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../../../native/landlock-run/packages/entry" + }, { "path": "../../util/brand" }, diff --git a/packages/examples/agent-spine-demo/package.json b/packages/examples/agent-spine-demo/package.json index a4b18b9c0c..b9f817d6e5 100644 --- a/packages/examples/agent-spine-demo/package.json +++ b/packages/examples/agent-spine-demo/package.json @@ -84,7 +84,7 @@ "@deepseek-ai/dsh-tool-tasks": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-workspace-context": "workspace:^", - "node-addon-landlock-run": "0.0.0-test.0", + "node-addon-landlock-run": "workspace:*", "cordis": "^4.0.0-rc.7" }, "dependencies": { diff --git a/packages/examples/agent-spine-demo/tsconfig.json b/packages/examples/agent-spine-demo/tsconfig.json index 6a0091a6f6..f245e9f4d0 100644 --- a/packages/examples/agent-spine-demo/tsconfig.json +++ b/packages/examples/agent-spine-demo/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../../../vendor/schemastery" }, + { + "path": "../../../native/landlock-run/packages/entry" + }, { "path": "../../llm/llm" }, diff --git a/packages/sandbox/sandbox-local/package.json b/packages/sandbox/sandbox-local/package.json index 2683ab3654..f7004c4d5c 100644 --- a/packages/sandbox/sandbox-local/package.json +++ b/packages/sandbox/sandbox-local/package.json @@ -31,7 +31,7 @@ "cordis": "^4.0.0-rc.7" }, "dependencies": { - "node-addon-landlock-run": "0.0.0-test.0", + "node-addon-landlock-run": "workspace:*", "schemastery": "^3.18.0" }, "devDependencies": { diff --git a/packages/sandbox/sandbox-local/tests/landlock.e2e.ts b/packages/sandbox/sandbox-local/tests/landlock.e2e.ts index f5ecbc67f9..6e2faecc6b 100644 --- a/packages/sandbox/sandbox-local/tests/landlock.e2e.ts +++ b/packages/sandbox/sandbox-local/tests/landlock.e2e.ts @@ -10,7 +10,7 @@ import { launcherPath } from 'node-addon-landlock-run' import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' /** - * Keyless backend integration through `confine()` and the registry `landlock-run` launcher, with + * Keyless backend integration through `confine()` and the workspace `landlock-run` launcher, with * bwrap forced off. Tests assert real world effects; consumer coverage lives in dsh-bash-sandbox. * Skips when the platform package or enforcing kernel is unavailable. HOME-based workspaces avoid * Landlock's wholesale `/tmp` grant, so workspace-write proves the workspace-root grant itself. diff --git a/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts b/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts index a032e1add7..caf0a32c68 100644 --- a/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts +++ b/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts @@ -7,20 +7,22 @@ import { fileURLToPath } from 'node:url' import { afterAll, beforeAll, describe, expect, it } from 'vitest' /** - * Keyless publish-path rehearsal. It packs the package and workspace peers, installs those exact - * tarballs in an external plain-Node consumer, and lets npm resolve the registry Landlock launcher - * plus its platform package. No tsx, path mapping, or workspace resolution can hide missing files, - * dependency errors, or lost executable modes. + * Keyless publish-path rehearsal. It packs the provider, its workspace peers, and the current + * repository's Landlock entry/platform packages, then installs those exact tarballs in an external + * plain-Node consumer. No registry copy, tsx, path mapping, or workspace resolution can hide + * missing files, dependency errors, or lost executable modes. * * The installed launcher must match the host architecture, remain executable, and either confine a * real process with bwrap disabled or fail closed on a non-enforcing kernel. Skips off Linux or - * before `pnpm run build`; launcher byte provenance belongs to its upstream release pipeline. + * before the harness and native packages are built. */ const packageDir = fileURLToPath(new URL('..', import.meta.url)) const repoRoot = fileURLToPath(new URL('../../../..', import.meta.url)) +const nativeDir = join(repoRoot, 'native/landlock-run') +const sourceLauncher = join(nativeDir, 'packages', `linux-${process.arch}`, 'bin', 'landlock-run') -/** The closure the consumer needs: the package and its transitive `@deepseek-ai` peers; the launcher family arrives from the registry. */ +/** The harness closure the consumer needs; native tarballs are packed through their mode-preserving release script. */ const WORKSPACE_CLOSURE = [ 'packages/sandbox/sandbox-local', 'packages/sandbox/sandbox', @@ -36,6 +38,8 @@ const E_MACHINE = { x64: 62, arm64: 183 }[process.arch as 'x64' | 'arm64'] const packable = process.platform === 'linux' && E_MACHINE !== undefined && existsSync(join(packageDir, 'lib', 'index.js')) + && existsSync(join(nativeDir, 'packages/entry/lib/index.js')) + && existsSync(sourceLauncher) let consumerDir = '' let workDir = '' @@ -57,7 +61,20 @@ describe.skipIf(!packable)('sandbox-local: packed-tarball distribution (publish- consumerDir = mkdtempSync(join(tmpdir(), 'dsh-packed-consumer-')) workDir = mkdtempSync(join(tmpdir(), 'dsh-packed-work-')) - // Pack each closure member with the exact bytes publish would upload. + const nativePackDest = join(packDest, 'native') + const nativePack = spawnSync('node', ['./scripts/pack-release.mjs', nativePackDest, '--current-platform-only'], { + cwd: nativeDir, + encoding: 'utf8', + timeout: 120_000, + }) + expect(nativePack.status, `native pack failed:\n${nativePack.stdout}\n${nativePack.stderr}`).toBe(0) + + const nativeTarballs = readFileSync(join(nativePackDest, 'publish-order.txt'), 'utf8') + .trim() + .split('\n') + .map(tarball => join(nativePackDest, tarball)) + + // Pack each harness closure member with the exact bytes publish would upload. const tarballs: string[] = [] for (const pkg of WORKSPACE_CLOSURE) { const pack = spawnSync('pnpm', ['pack', '--pack-destination', packDest], { @@ -69,6 +86,7 @@ describe.skipIf(!packable)('sandbox-local: packed-tarball distribution (publish- const lines = pack.stdout.trim().split('\n') tarballs.push(lines[lines.length - 1] as string) } + tarballs.push(...nativeTarballs) // Peer ranges resolve to the tarballs; Cordis is pinned to their peer range. Do not omit optional // dependencies because the launcher selects its OS/CPU package through one. @@ -124,12 +142,13 @@ describe.skipIf(!packable)('sandbox-local: packed-tarball distribution (publish- await Promise.all([consumerDir, workDir].filter(Boolean).map(dir => rm(dir, { recursive: true, force: true }))) }) - it('installs the registry launcher for this host: present, EXECUTABLE, right ELF arch', () => { + it('installs this checkout\'s launcher for the host: present, executable, byte-identical, and right ELF arch', () => { const installed = join(consumerDir, 'node_modules', `node-addon-landlock-run-linux-${process.arch}`, 'bin', 'landlock-run') expect(existsSync(installed), 'platform package missing from the installed tree').toBe(true) // A tarball or extraction step that strips the mode bit would leave the // probe failing exactly like a non-enforcing kernel — assert it apart. expect(() => { accessSync(installed, constants.X_OK) }, 'installed launcher is not executable').not.toThrow() + expect(readFileSync(installed), 'installed launcher bytes').toEqual(readFileSync(sourceLauncher)) expect(readFileSync(installed).readUInt16LE(18), 'ELF e_machine').toBe(E_MACHINE) }) diff --git a/packages/sandbox/sandbox-local/tsconfig.json b/packages/sandbox/sandbox-local/tsconfig.json index 608f0e9568..7a41ffc5fd 100644 --- a/packages/sandbox/sandbox-local/tsconfig.json +++ b/packages/sandbox/sandbox-local/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../../../vendor/schemastery" }, + { + "path": "../../../native/landlock-run/packages/entry" + }, { "path": "../../llm/llm" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 01a5a2f64c..feedce51c0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -851,6 +851,34 @@ importers: specifier: workspace:* version: link:../packages/context/workspace-context + native/landlock-run: + devDependencies: + '@types/node': + specifier: ^26.0.1 + version: 26.1.2 + node-addon-landlock-run: + specifier: workspace:* + version: link:packages/entry + tsx: + specifier: ^4.20.6 + version: 4.22.4 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + + native/landlock-run/packages/entry: + optionalDependencies: + node-addon-landlock-run-linux-arm64: + specifier: workspace:* + version: link:../linux-arm64 + node-addon-landlock-run-linux-x64: + specifier: workspace:* + version: link:../linux-x64 + + native/landlock-run/packages/linux-arm64: {} + + native/landlock-run/packages/linux-x64: {} + packages/acp/acp: dependencies: '@agentclientprotocol/sdk': @@ -986,8 +1014,8 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis node-addon-landlock-run: - specifier: 0.0.0-test.0 - version: 0.0.0-test.0 + specifier: workspace:* + version: link:../../../native/landlock-run/packages/entry packages/bash/pwsh-local: dependencies: @@ -1306,7 +1334,7 @@ importers: version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) vitest: specifier: ^4.1.8 - version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) devDependencies: '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ @@ -2939,8 +2967,8 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis node-addon-landlock-run: - specifier: 0.0.0-test.0 - version: 0.0.0-test.0 + specifier: workspace:* + version: link:../../../native/landlock-run/packages/entry packages/examples/cli-demo: devDependencies: @@ -4219,8 +4247,8 @@ importers: packages/sandbox/sandbox-local: dependencies: node-addon-landlock-run: - specifier: 0.0.0-test.0 - version: 0.0.0-test.0 + specifier: workspace:* + version: link:../../../native/landlock-run/packages/entry schemastery: specifier: ^3.18.0 version: link:../../../vendor/schemastery @@ -5464,7 +5492,7 @@ importers: version: link:../loader-smoke vitest: specifier: ^4.1.8 - version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) devDependencies: '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -9113,6 +9141,9 @@ packages: '@types/node@25.9.3': resolution: {integrity: sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==} + '@types/node@26.1.2': + resolution: {integrity: sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==} + '@types/picomatch@3.0.2': resolution: {integrity: sha512-n0i8TD3UDB7paoMMxA3Y65vUncFJXjcUf7lQY7YyKGl6031FNjfsLs6pdLFCy2GNFxItPJG8GvvpbZc2skH7WA==} @@ -11011,22 +11042,6 @@ packages: node-addon-api@7.1.1: resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} - node-addon-landlock-run-linux-arm64@0.0.0-test.0: - resolution: {integrity: sha512-oJsXcC33qKl9mWYx0n9YPJ2pUAoY39PoIX0Gx4lDrSCTEvENFrEaODAsQYNY+eEGpn9YMN7E+FOftvea3/1FqQ==} - engines: {node: '>=20'} - cpu: [arm64] - os: [linux] - - node-addon-landlock-run-linux-x64@0.0.0-test.0: - resolution: {integrity: sha512-eXvdfnH/UV55MTZzroKvM3CD68SP5OlCsuth908YOcJOnn0LPD5KJjmBz6ToDlBYjF52NNK62+g7TvmUWjbKWQ==} - engines: {node: '>=20'} - cpu: [x64] - os: [linux] - - node-addon-landlock-run@0.0.0-test.0: - resolution: {integrity: sha512-c5qopltRonjW6+VinXYMp4FVi9Sxf8eQEb/9G82EksQQ+JC4CDprv+ko5URWmtyZ3wD4GFdeRTyw1AG5yCwJhQ==} - engines: {node: '>=20'} - node-addon-native-custom-loader@0.1.4: resolution: {integrity: sha512-DreegO6EoC1JHWYBv3j8Miwp2Zl/CyBeNyoeyCbnEdjyYFEulR4Gcb3wj9fXF7KMDY0ZJ5MWwHcXP8GVNyScnA==} engines: {node: '>=20'} @@ -11815,6 +11830,9 @@ packages: undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + undici@7.28.0: resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} engines: {node: '>=20.18.1'} @@ -14189,6 +14207,10 @@ snapshots: dependencies: undici-types: 7.24.6 + '@types/node@26.1.2': + dependencies: + undici-types: 8.3.0 + '@types/picomatch@3.0.2': {} '@types/prop-types@15.7.15': {} @@ -14344,13 +14366,13 @@ snapshots: optionalDependencies: vite: 8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) - '@vitest/mocker@4.1.8(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': + '@vitest/mocker@4.1.8(vite@8.0.16(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.8 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + vite: 8.0.16(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) '@vitest/pretty-format@4.1.8': dependencies: @@ -16476,17 +16498,6 @@ snapshots: node-addon-api@7.1.1: {} - node-addon-landlock-run-linux-arm64@0.0.0-test.0: - optional: true - - node-addon-landlock-run-linux-x64@0.0.0-test.0: - optional: true - - node-addon-landlock-run@0.0.0-test.0: - optionalDependencies: - node-addon-landlock-run-linux-arm64: 0.0.0-test.0 - node-addon-landlock-run-linux-x64: 0.0.0-test.0 - node-addon-native-custom-loader@0.1.4: {} node-addon-require-builtin-darwin-arm64@0.1.4: @@ -17398,6 +17409,8 @@ snapshots: undici-types@7.24.6: {} + undici-types@8.3.0: {} + undici@7.28.0: {} unicorn-magic@0.3.0: {} @@ -17533,7 +17546,7 @@ snapshots: tsx: 4.22.4 yaml: 2.9.0 - vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0): + vite@8.0.16(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -17541,7 +17554,7 @@ snapshots: rolldown: 1.0.3 tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 25.9.3 + '@types/node': 26.1.2 esbuild: 0.28.1 fsevents: 2.3.3 jiti: 2.7.0 @@ -17635,10 +17648,10 @@ snapshots: transitivePeerDependencies: - msw - vitest@4.1.8(@opentelemetry/api@1.9.0)(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): + vitest@4.1.8(@opentelemetry/api@1.9.0)(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.8 - '@vitest/mocker': 4.1.8(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + '@vitest/mocker': 4.1.8(vite@8.0.16(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.8 '@vitest/runner': 4.1.8 '@vitest/snapshot': 4.1.8 @@ -17655,7 +17668,7 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + vite: 8.0.16(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.0 @@ -17695,10 +17708,10 @@ snapshots: transitivePeerDependencies: - msw - vitest@4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): + vitest@4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.8 - '@vitest/mocker': 4.1.8(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + '@vitest/mocker': 4.1.8(vite@8.0.16(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.8 '@vitest/runner': 4.1.8 '@vitest/snapshot': 4.1.8 @@ -17715,7 +17728,7 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + vite: 8.0.16(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index fec185b114..66510d89ec 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,10 @@ packages: - vendor/* - packages/*/* + # The Landlock launcher is developed with its harness consumers but keeps + # its native build and publication scripts under native/landlock-run. + - native/landlock-run + - native/landlock-run/packages/* # Product assemblies over the package tier; apps/cli owns the `dsh` bin. - apps/* - website @@ -46,14 +50,7 @@ allowBuilds: # restores the executable bit on node-pty's macOS spawn helper. '@deepseek-ai/dsh-pty-local@file:packages/pty/pty-local': true -# The Landlock launcher family is our own sibling-repo release, consumed -# fresh (hours old at each coordinated bump) — the release-age quarantine -# would block every such bump, so the family is exempted BY NAME, not by -# pinned version. minimumReleaseAgeExclude: - - node-addon-landlock-run - - node-addon-landlock-run-linux-arm64 - - node-addon-landlock-run-linux-x64 # Cordis release candidates are source-vendored and pinned in vendor/README.md # during the same-day sync that updates package manifests and the lockfile. - '@cordisjs/plugin-loader@1.0.0-rc.5' diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 6bd613c2ea..32e2096aa9 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -15,6 +15,8 @@ const root = resolve(import.meta.dirname, '..') const workspaceGlobs = [ { dir: 'vendor', depth: 1 }, { dir: 'packages', depth: 2 }, + { dir: 'native', depth: 1 }, + { dir: 'native/landlock-run/packages', depth: 1 }, { dir: 'apps', depth: 1 }, ] as const const vendoredPackages = new Set([ @@ -28,6 +30,11 @@ const vendoredPackages = new Set([ '@cordisjs/plugin-hmr', '@cordisjs/plugin-logger-console', ]) +const publicLandlockPackages = new Set([ + 'node-addon-landlock-run', + 'node-addon-landlock-run-linux-arm64', + 'node-addon-landlock-run-linux-x64', +]) const localArtifactDirs = new Set(['node_modules']) const appPackageFiles: Readonly> = { @@ -55,6 +62,7 @@ interface PackageManifest { | undefined > files?: string[] + publishConfig?: { access?: string } peerDependencies?: Record devDependencies?: Record } @@ -71,6 +79,8 @@ function readJson(path: string): PackageManifest { const rootManifest = readJson(join(root, 'package.json')) const repositoryVersion = rootManifest.version +const landlockWorkspaceManifest = readJson(join(root, 'native/landlock-run/package.json')) +const landlockVersion = landlockWorkspaceManifest.version /** Repo-relative dirs holding a package.json, walked to the configured depth. */ function packageDirs(base: string, depth: number): string[] { @@ -161,8 +171,19 @@ function usesEmittedTreeDefaults(manifest: PackageManifest): boolean { function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] { const errors: string[] = [] const label = manifest.name ?? dir + const isLandlockPackageDir = dir.startsWith('native/landlock-run/packages/') + const isPublicLandlockPackage = isLandlockPackageDir + && manifest.name !== undefined + && publicLandlockPackages.has(manifest.name) - if (manifest.private !== true) { + if (isPublicLandlockPackage) { + if (manifest.private === true) { + errors.push(`${label}: published Landlock package must not set "private": true`) + } + if (manifest.publishConfig?.access !== 'public') { + errors.push(`${label}: published Landlock package must set publishConfig.access to "public"`) + } + } else if (manifest.private !== true) { errors.push(`${label}: package.json must set "private": true`) } @@ -187,6 +208,15 @@ function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] { } } + if (isLandlockPackageDir) { + if (!isPublicLandlockPackage) { + errors.push(`${label}: unexpected package in the public Landlock package family`) + } + if (manifest.version !== landlockVersion) { + errors.push(`${label}: package.json version must match Landlock workspace version ${landlockVersion ?? '(missing)'}`) + } + } + if (dir.startsWith('packages/') && manifest.name?.startsWith('@deepseek-ai/dsh-')) { const peer = manifest.peerDependencies?.cordis const dev = manifest.devDependencies?.cordis diff --git a/scripts/clean.spec.ts b/scripts/clean.spec.ts index 0a46764d9a..aada0667ef 100644 --- a/scripts/clean.spec.ts +++ b/scripts/clean.spec.ts @@ -18,10 +18,10 @@ function write(path: string, content = ''): void { writeFileSync(path, content) } -function addProject(root: string, path: string): void { +function addProject(root: string, path: string, outDir = 'lib/types'): void { write(join(root, 'tsconfig.json'), JSON.stringify({ files: [], references: [{ path }] })) write(join(root, path, 'tsconfig.json'), JSON.stringify({ - compilerOptions: { composite: true, outDir: 'lib/types' }, + compilerOptions: { composite: true, outDir }, include: ['src'], })) write(join(root, path, 'src/index.ts'), 'export {}\n') @@ -60,6 +60,18 @@ describe('RepositoryCleaner', () => { expect(existsSync(join(root, 'products/shell/lib'))).toBe(true) }) + it('removes the native Landlock entry output that emits directly to lib', async () => { + const root = fixture() + const entry = 'native/landlock-run/packages/entry' + addProject(root, entry, 'lib') + write(join(root, entry, 'lib/index.js')) + + await new RepositoryCleaner(root).clean() + + expect(existsSync(join(root, entry, 'lib'))).toBe(false) + expect(existsSync(join(root, entry, 'src/index.ts'))).toBe(true) + }) + it('refuses project outputs reached through a symlink outside the repository', async () => { const root = fixture() const externalProject = fixture() diff --git a/scripts/clean.ts b/scripts/clean.ts index fff158c458..1224fe8420 100644 --- a/scripts/clean.ts +++ b/scripts/clean.ts @@ -114,6 +114,7 @@ export class RepositoryCleaner { const outputs = new Set() const pending = [join(this.root, 'tsconfig.json')] const visited = new Set() + const nativeEntryOutput = join(this.root, 'native/landlock-run/packages/entry/lib') while (pending.length > 0) { const nextConfigPath = pending.pop() @@ -125,10 +126,14 @@ export class RepositoryCleaner { const parsed = parseConfig(configPath) if (parsed.options.outDir !== undefined) { const typesDirectory = resolve(parsed.options.outDir) - if (basename(typesDirectory) !== 'types') { + const outputDirectory = basename(typesDirectory) === 'types' + ? dirname(typesDirectory) + : typesDirectory === nativeEntryOutput + ? typesDirectory + : undefined + if (outputDirectory === undefined) { throw new Error(`clean: expected TypeScript outDir to end in /types: ${repositoryPath(this.root, typesDirectory)}`) } - const outputDirectory = dirname(typesDirectory) this.assertRepositoryTarget(outputDirectory) outputs.add(outputDirectory) } diff --git a/scripts/gen-third-party-notices.spec.ts b/scripts/gen-third-party-notices.spec.ts index f31cca6879..6db1f6ea8d 100644 --- a/scripts/gen-third-party-notices.spec.ts +++ b/scripts/gen-third-party-notices.spec.ts @@ -247,13 +247,13 @@ describe('isPermissive', () => { describe('manifestPatterns', () => { it('derives globs from the declared members, so a new member area is read', () => { - expect(manifestPatterns(['packages/*/*', 'tools/*'], ['packages/*'])).toEqual([ + expect(manifestPatterns(['packages/*/*', 'tools/*', 'native/landlock-run', 'native/landlock-run/packages/*'])).toEqual([ 'package.json', 'packages/*/*/package.json', 'tools/*/package.json', - 'examples/*/package.json', 'native/landlock-run/package.json', 'native/landlock-run/packages/*/package.json', + 'examples/*/package.json', ]) }) }) diff --git a/scripts/gen-third-party-notices.ts b/scripts/gen-third-party-notices.ts index 0d41953e4b..d7e0c26a5f 100644 --- a/scripts/gen-third-party-notices.ts +++ b/scripts/gen-third-party-notices.ts @@ -39,10 +39,7 @@ const DEV_ONLY_AREAS = [ 'native/', ] as const -/** - * First-party packages released from sibling repositories under the project's - * own license: reachable from workspace manifests but not third-party. - */ +/** First-party public native packages: reachable at runtime but not third-party. */ const FIRST_PARTY = new Set([ 'node-addon-landlock-run', 'node-addon-landlock-run-linux-arm64', @@ -119,16 +116,13 @@ function readManifest(rel: string): Manifest { * here, so a new member area (`tools/*`) is read the day it is declared. * @returns one glob per manifest-bearing location, repository-relative. */ -export function manifestPatterns(rootMembers: readonly string[], nativeMembers: readonly string[]): string[] { +export function manifestPatterns(rootMembers: readonly string[]): string[] { return [ 'package.json', ...rootMembers.map(member => `${member}/package.json`), // The demo leaves join the workspace through `examples/package.json`, so // their own manifests are members of nothing and no glob above reaches them. 'examples/*/package.json', - // `native/landlock-run` is a nested workspace with its own lock file. - 'native/landlock-run/package.json', - ...nativeMembers.map(member => `native/landlock-run/${member}/package.json`), ] } @@ -149,7 +143,7 @@ function workspaceMembers(rel: string): string[] { * would silently push dev-area manifests into the runtime tier. */ function loadWorkspaceManifests(): { manifests: Map; names: Set } { - const patterns = manifestPatterns(workspaceMembers('pnpm-workspace.yaml'), workspaceMembers('native/landlock-run/pnpm-workspace.yaml')) + const patterns = manifestPatterns(workspaceMembers('pnpm-workspace.yaml')) const manifests = new Map() const names = new Set() for (const pattern of patterns) { @@ -591,9 +585,9 @@ ${python.map(dep => `| [\`${dep.name}\`](${dep.repo}) | ${dep.license} | ${dep.r | --- | --- | --- | ${BUILD_TIME_TOOLS.map(tool => `| [\`${tool.name}\`](${tool.repo}) | ${tool.license} | ${tool.role} |`).join('\n')} -## First-party sibling releases +## First-party native packages -\`node-addon-landlock-run\` (and its platform packages) is released from a DeepSeek Harness sibling repository under BSD 3-Clause. It is listed here for completeness; it is first-party, not third-party. +\`node-addon-landlock-run\` (and its platform packages) is built and released from this repository under BSD 3-Clause. It is listed here for completeness; it is first-party, not third-party. ` } diff --git a/tsconfig.base.json b/tsconfig.base.json index 9ba9ba5d84..634464cb9b 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -38,6 +38,7 @@ "@cordisjs/plugin-timer": ["./vendor/timer/src"], "@cordisjs/plugin-hmr": ["./vendor/hmr/src"], "@cordisjs/plugin-logger-console": ["./vendor/logger-console/src"], + "node-addon-landlock-run": ["./native/landlock-run/packages/entry/src/index.ts"], "@deepseek-ai/dsh-invariants": ["./packages/support/invariants/src/index.ts"], "@deepseek-ai/dsh-typert-registry": ["./packages/typert/registry/src/index.ts"], "@deepseek-ai/dsh-typert-loader": ["./packages/typert/loader/src/index.ts"], diff --git a/tsconfig.host.json b/tsconfig.host.json index c13d480a46..f6b125339a 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -156,6 +156,7 @@ { "path": "./packages/bash/bash-env" }, { "path": "./packages/bash/pwsh-local" }, { "path": "./packages/bash/tool-pwsh" }, + { "path": "./native/landlock-run/packages/entry" }, { "path": "./packages/sandbox/sandbox" }, { "path": "./packages/sandbox/sandbox-local" }, { "path": "./packages/sandbox/sandbox-policy" }, From d3aa337c26806d14e45faf1319bb3d2ceada6cd5 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 6 Aug 2026 10:52:46 +0800 Subject: [PATCH 020/109] fix(landlock-run): close release integration gaps (review round 2) --- ...2026-07-27-dependabot-version-updates.i18n.yaml | 4 ++-- .../2026-07-27-dependabot-version-updates.md | 10 +++++----- .../2026-07-27-dependabot-version-updates.zh.md | 10 +++++----- ...6-07-30-generated-third-party-notices.i18n.yaml | 4 ++-- .../2026-07-30-generated-third-party-notices.md | 2 +- .../2026-07-30-generated-third-party-notices.zh.md | 2 +- .github/dependabot.yml | 14 -------------- THIRD_PARTY_NOTICES.md | 2 +- native/landlock-run/packages/entry/package.json | 5 +++++ .../landlock-run/packages/linux-arm64/package.json | 5 +++++ .../landlock-run/packages/linux-x64/package.json | 5 +++++ scripts/check-workspace-constraints.ts | 12 ++++++++++-- scripts/gen-third-party-notices.ts | 8 ++++---- 13 files changed, 46 insertions(+), 37 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.i18n.yaml b/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.i18n.yaml index 07c742c518..316c31771e 100644 --- a/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.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-07-27-dependabot-version-updates.md -2026-07-27-dependabot-version-updates.md: 725649652c5b91ba4897d03b548b9aa5c3694c21 -2026-07-27-dependabot-version-updates.zh.md: 6400ba8ed94bf138fcece90e5d7ff82886d33ed1 +2026-07-27-dependabot-version-updates.md: 5d42563788d9f1e72da65c8e9750d6b1ecba06a5 +2026-07-27-dependabot-version-updates.zh.md: 4847059944e7e35de5719a6cbfd3d5b133467ccb diff --git a/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.md b/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.md index 725649652c..5d42563788 100644 --- a/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.md +++ b/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.md @@ -6,23 +6,23 @@ English | [中文](2026-07-27-dependabot-version-updates.zh.md) ## Problem -Maintained registry and GitHub Actions dependencies need a regular update path. Adopting every release immediately increases exposure to compromised releases and early regressions, while leaving updates entirely manual lets dependency drift accumulate. Vendored Cordis sources and independently locked workspaces also cannot be treated as one undifferentiated package tree. +Maintained registry and GitHub Actions dependencies need a regular update path. Adopting every release immediately increases exposure to compromised releases and early regressions, while leaving updates entirely manual lets dependency drift accumulate. Vendored Cordis sources cannot be treated like registry dependencies, and workspaces sharing one lockfile must be updated through the same package tree. ## Decision -The default branch carries [`.github/dependabot.yml`](../../../../.github/dependabot.yml) with weekly version-update checks for the root pnpm workspace, the independently locked `native/landlock-run` pnpm workspace, the `python/sdk` uv project, and GitHub Actions. Every entry sets `cooldown.default-days` to `30`, so a version release becomes eligible only after it is at least 30 days old and is proposed on the next weekly check. +The default branch carries [`.github/dependabot.yml`](../../../../.github/dependabot.yml) with weekly version-update checks for the root pnpm workspace, including `native/landlock-run`, the `python/sdk` uv project, and GitHub Actions. Every entry sets `cooldown.default-days` to `30`, so a version release becomes eligible only after it is at least 30 days old and is proposed on the next weekly check. The [in-repository Landlock release decision](2026-08-06-in-repository-landlock-release.md) owns the shared-workspace boundary. -The root pnpm version-update scan excludes `vendor/**`, whose source and manifests move only through the [vendoring procedure](../../../../vendor/README.md), and `native/landlock-run/**`, which its dedicated entry owns. GitHub applies `exclude-paths` only to version updates; a security pull request that touches a vendored manifest is replaced through the vendoring procedure instead of being merged as generated. Dependabot pull requests receive the repository's `cleanup` kind and `area/infra` area labels, run the normal pull-request checks, and remain subject to maintainer review; this automation does not merge them. +The root pnpm version-update scan excludes `vendor/**`, whose source and manifests move only through the [vendoring procedure](../../../../vendor/README.md). GitHub applies `exclude-paths` only to version updates; a security pull request that touches a vendored manifest is replaced through the vendoring procedure instead of being merged as generated. Dependabot pull requests receive the repository's `cleanup` kind and `area/infra` area labels, run the normal pull-request checks, and remain subject to maintainer review; this automation does not merge them. Repository settings enable dependency vulnerability alerts and Dependabot security updates. GitHub does not apply version-update cooldowns to those security updates, so security fixes remain eligible immediately. A generated pnpm security pull request can still fail the repository's lockfile release-age verification when dependency resolution selects unrelated fresh transitive versions; that pull request waits or is narrowed instead of weakening the policy. The repository's coordinated fresh-release exceptions are not copied into Dependabot's cooldown exclusions: automated version updates use the uniform 30-day wait, while an explicitly reviewed manual update can still follow its owning release procedure. -The pnpm entries keep both workspaces on their pinned pnpm 11 instead of introducing an automation-only downgrade. The current Dependabot updater installs the version requested by `packageManager` and reads both workspaces' lockfile format `9.0`; the provider-run update job remains the integration check. +The pnpm entry keeps the unified workspace on its pinned pnpm 11 instead of introducing an automation-only downgrade. The current Dependabot updater installs the version requested by the root `packageManager` and reads the root lockfile format `9.0`; the provider-run update job remains the integration check. ## Alternatives considered - **Immediate version updates.** Rejected because they remove the requested release-age quarantine and make the project an early consumer of every upstream release. - **Automatic merging after CI.** Rejected because dependency changes can alter runtime, build, and release behavior; the normal review decision remains part of accepting an update. -- **One recursive npm scan.** Rejected because it could admit vendored manifests or conflate the root and native lockfiles. Explicit exclusions and a dedicated native entry preserve their ownership boundaries. +- **A separate native npm scan.** Rejected because the Landlock manifests belong to the root workspace and lockfile; splitting their update would recreate an ownership boundary the package manager no longer has. The root scan excludes only vendored manifests. - **Renovate or a scheduled agent.** Both can propose aged updates, but Dependabot is the requested service and the repository's CI already recognizes its pull requests as an untrusted dependency source. - **Cooldown exemptions for coordinated fresh releases.** Rejected for the automated path because those releases require an explicit synchronization or model-catalog decision rather than a generic update proposal. diff --git a/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.zh.md b/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.zh.md index 6400ba8ed9..4847059944 100644 --- a/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.zh.md +++ b/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.zh.md @@ -6,23 +6,23 @@ Status: implemented ## 问题 -来自包注册表的依赖与 GitHub Actions 依赖都需要定期更新机制。每个新版本一经发布便立即采用,会增加受到遭入侵的版本和早期回归影响的风险;但完全依靠手动更新,又会导致依赖版本差距持续扩大。以源码形式纳入仓库的 Cordis 与各自维护独立锁文件的工作区,也不能不加区分地视为同一棵包树。 +来自包注册表的依赖与 GitHub Actions 依赖都需要定期更新机制。每个新版本一经发布便立即采用,会增加受到遭入侵的版本和早期回归影响的风险;但完全依靠手动更新,又会导致依赖版本差距持续扩大。以源码形式纳入仓库的 Cordis 不能当作注册表依赖处理,而共用一份锁文件的工作区必须通过同一棵包树更新。 ## 决策 -默认分支包含 [`.github/dependabot.yml`](../../../../.github/dependabot.yml),其中为根 pnpm 工作区、独立维护锁文件的 `native/landlock-run` pnpm 工作区、`python/sdk` uv 项目和 GitHub Actions 配置了每周一次的版本更新检查。每个更新项都将 `cooldown.default-days` 设为 `30`,因此某个版本只有在发布至少 30 天后才符合更新条件,并会在下一次每周检查时生成更新提案。 +默认分支包含 [`.github/dependabot.yml`](../../../../.github/dependabot.yml),其中为包含 `native/landlock-run` 的根 pnpm 工作区、`python/sdk` uv 项目和 GitHub Actions 配置了每周一次的版本更新检查。每个更新项都将 `cooldown.default-days` 设为 `30`,因此某个版本只有在发布至少 30 天后才符合更新条件,并会在下一次每周检查时生成更新提案。[仓库内 Landlock 发布决策](2026-08-06-in-repository-landlock-release.md)负责共享工作区边界。 -根 pnpm 工作区的版本更新扫描排除 `vendor/**`,其中的源码和 manifest(元数据清单)只能通过 [vendoring 流程](../../../../vendor/README.md)变更;扫描还排除由专用更新项负责的 `native/landlock-run/**`。GitHub 仅将 `exclude-paths` 用于版本更新;如果安全更新 PR(Pull Request)涉及随源码纳入仓库的 manifest,则改由 vendoring 流程处理,以替代自动生成的 PR,而不会将其原样合并。Dependabot PR 会获得仓库的 `cleanup` 类型标签和 `area/infra` 区域标签,运行常规 PR 检查,并且仍须由维护者评审;该自动化不会合并这些 PR。 +根 pnpm 工作区的版本更新扫描排除 `vendor/**`,其中的源码和 manifest(元数据清单)只能通过 [vendoring 流程](../../../../vendor/README.md)变更。GitHub 仅将 `exclude-paths` 用于版本更新;如果安全更新 PR(Pull Request)涉及随源码纳入仓库的 manifest,则改由 vendoring 流程处理,以替代自动生成的 PR,而不会将其原样合并。Dependabot PR 会获得仓库的 `cleanup` 类型标签和 `area/infra` 区域标签,运行常规 PR 检查,并且仍须由维护者评审;该自动化不会合并这些 PR。 仓库设置已启用依赖项漏洞警报和 Dependabot 安全更新。GitHub 不会对这些安全更新应用版本更新冷却期,因此安全修复仍可立即进入更新流程。如果依赖解析还选中了其他刚发布的传递依赖,pnpm 安全更新 PR 仍可能无法通过仓库的锁文件发布时长校验;此类 PR 应等待隔离期结束或缩小更新范围,不得因此放宽政策。仓库为协调刚发布版本而设置的例外,不会纳入 Dependabot 的冷却期排除项:自动版本更新统一等待 30 天;经过明确评审的手动更新仍可遵循相应的发布流程。 -pnpm 更新项让两个工作区继续使用已固定的 pnpm 11,不会仅为了自动化而降级版本。当前 Dependabot 更新器会安装 `packageManager` 指定的版本,并读取两个工作区使用的 `9.0` 锁文件格式;由提供方运行的更新任务仍作为集成检查。 +pnpm 更新项让统一工作区继续使用已固定的 pnpm 11,不会仅为了自动化而降级版本。当前 Dependabot 更新器会安装根 `packageManager` 指定的版本,并读取根锁文件的 `9.0` 格式;由提供方运行的更新任务仍作为集成检查。 ## 考虑过的替代方案 - **立即进行版本更新。** 不采用,因为这会取消所要求的版本发布后隔离期,使项目在每个上游版本的发布初期就采用该版本。 - **CI 通过后自动合并。** 不采用,因为依赖变更可能改变运行时、构建和发布行为;是否接受更新仍须经过常规评审决策。 -- **使用一次递归 npm 扫描。** 不采用,因为它可能将随源码纳入仓库的 manifest 纳入更新范围,或混淆根工作区与 native 工作区的锁文件。显式排除项和专用 native 更新项可维持各自的归属边界。 +- **为 native 配置独立的 npm 扫描。** 不采用,因为 Landlock manifest 属于根工作区和根锁文件;拆分更新会重建一个包管理器已不存在的归属边界。根扫描仅排除随源码纳入的 manifest。 - **Renovate 或定期运行的 agent(智能体)。** 二者都能为发布已满一定时长的版本提出更新,但所要求的服务是 Dependabot,而且仓库 CI 已将其 PR 视为不可信的依赖来源。 - **为需协调的刚发布版本设置冷却期豁免。** 自动化路径不采用,因为此类版本需要明确的同步决策或模型目录决策,不能由通用更新提案代替。 diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml index d65dae2802..afe8cdba57 100644 --- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.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-07-30-generated-third-party-notices.md -2026-07-30-generated-third-party-notices.md: e480954d29d5dc09ef8ecd4069059a1f0c8b1043 -2026-07-30-generated-third-party-notices.zh.md: 78ba7250e797c57048078d1b4f62b7a9a5d9d561 +2026-07-30-generated-third-party-notices.md: 6a95953bc551cb38ca1d9aaa51a2041deadd1b08 +2026-07-30-generated-third-party-notices.zh.md: 9d48d3b39de76d5f4fbc1e2e9593a933b08583c8 diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md index e480954d29..6a95953bc5 100644 --- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md @@ -24,7 +24,7 @@ The file discloses **direct** dependencies only. The complete npm closure with p The runtime tier deliberately covers **every mountable plugin**, not just what the CLI, Web UI, and Python runtime load by default. `scripts/install.sh` installs the repository itself, so a user's `cordis.yml` can mount any plugin package; `@modelcontextprotocol/sdk` and the OpenTelemetry packages reach real users even though no default assembly imports them. Under-disclosure is the costly direction for a legal notice. -The manifest set is derived from the `packages:` members each `pnpm-workspace.yaml` declares — the root one and the nested Landlock workspace's — so a new member area is read the day it is declared rather than the day someone remembers to extend a list. License and repository metadata come from the installed pnpm stores, both the root one and the Landlock workspace's, so the generator requires an installed tree and fails loud when a package resolves to neither, rather than emitting an empty cell. `OVERRIDES` carries the packages whose published manifest cannot answer — Rust-built npm bins that omit `license`, and the `modelcontextprotocol/servers` packages whose repository is mid MIT→Apache-2.0 relicensing, so their effective terms are per-contribution. A runtime dependency whose license is not on the permissive list is a hard error: shipping copyleft is a distribution decision, not something a regenerated table may absorb silently. Vendored packages are cross-checked against `vendor/README.md` and rejected if any is not MIT, and `pnpm-workspace.yaml`'s `patchedDependencies` are listed under the runtime table because pnpm applies those patches at install time — shipped artifacts carry modified copies of `@earendil-works/pi-tui` and `node-pty`, and the patch files are the record of what changed. +The manifest set is derived from the `packages:` members the root `pnpm-workspace.yaml` declares, including the Landlock workspace and its public packages, so a new member area is read the day it is declared rather than the day someone remembers to extend a list. License and repository metadata come from the root workspace's installed pnpm store and package-local link farms, so the generator requires an installed tree and fails loud when a package resolves to neither, rather than emitting an empty cell. `OVERRIDES` carries the packages whose published manifest cannot answer — Rust-built npm bins that omit `license`, and the `modelcontextprotocol/servers` packages whose repository is mid MIT→Apache-2.0 relicensing, so their effective terms are per-contribution. A runtime dependency whose license is not on the permissive list is a hard error: shipping copyleft is a distribution decision, not something a regenerated table may absorb silently. Vendored packages are cross-checked against `vendor/README.md` and rejected if any is not MIT, and `pnpm-workspace.yaml`'s `patchedDependencies` are listed under the runtime table because pnpm applies those patches at install time — shipped artifacts carry modified copies of `@earendil-works/pi-tui` and `node-pty`, and the patch files are the record of what changed. ## Testing diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md index 78ba7250e7..9d48d3b39d 100644 --- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md @@ -24,7 +24,7 @@ Status: implemented 运行时层刻意覆盖**所有可挂载的插件**,而不止 CLI、Web UI 与 Python 运行时默认加载的那些。`scripts/install.sh` 安装的就是仓库本身,用户的 `cordis.yml` 可以挂载任何插件包;`@modelcontextprotocol/sdk` 与 OpenTelemetry 系列即使没有任何默认装配引入,也会触达真实用户。对法务披露而言,披露不足才是代价更高的那个方向。 -清单集合由两个 `pnpm-workspace.yaml`——根工作区与嵌套的 Landlock 工作区——各自声明的 `packages:` 成员派生,因此新增成员区域在声明当天就会被读取,而不必等谁想起来去补一份列表。许可证与仓库地址取自已安装的 pnpm store,根 store 与 Landlock 工作区的 store 都会查;某个包两处都解析不到时直接失败,而不是留下空单元格。`OVERRIDES` 收录已发布清单答不上来的包:用 Rust 构建、发布时省略 `license` 字段的 npm 可执行包,以及 `modelcontextprotocol/servers` 系列——该仓库正处在 MIT 向 Apache-2.0 的重新许可过程中,实际条款按贡献逐条而定。运行时依赖的许可证若不在宽松清单内即为硬失败:交付 copyleft 是一项分发决策,不该被一次重新生成悄悄吸收。被源码收编的包会与 `vendor/README.md` 交叉核对,出现非 MIT 即报错;`pnpm-workspace.yaml` 的 `patchedDependencies` 列在运行时表格之后,因为 pnpm 在安装期就会打上这些补丁——交付产物携带的是改动过的 `@earendil-works/pi-tui` 与 `node-pty`,补丁文件本身就是改动的完整记录。 +清单集合由根 `pnpm-workspace.yaml` 声明的 `packages:` 成员派生,其中包括 Landlock 工作区及其公开包,因此新增成员区域在声明当天就会被读取,而不必等谁想起来去补一份列表。许可证与仓库地址取自根工作区已安装的 pnpm store 和包本地链接场;某个包两处都解析不到时直接失败,而不是留下空单元格。`OVERRIDES` 收录已发布清单答不上来的包:用 Rust 构建、发布时省略 `license` 字段的 npm 可执行包,以及 `modelcontextprotocol/servers` 系列——该仓库正处在 MIT 向 Apache-2.0 的重新许可过程中,实际条款按贡献逐条而定。运行时依赖的许可证若不在宽松清单内即为硬失败:交付 copyleft 是一项分发决策,不该被一次重新生成悄悄吸收。被源码收编的包会与 `vendor/README.md` 交叉核对,出现非 MIT 即报错;`pnpm-workspace.yaml` 的 `patchedDependencies` 列在运行时表格之后,因为 pnpm 在安装期就会打上这些补丁——交付产物携带的是改动过的 `@earendil-works/pi-tui` 与 `node-pty`,补丁文件本身就是改动的完整记录。 ## Testing diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 81052d7c08..524d7912e2 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -6,20 +6,6 @@ updates: exclude-paths: # Vendored Cordis sources follow vendor/README.md instead of registry updates. - "vendor/**" - # This independently locked pnpm workspace has its own update entry below. - - "native/landlock-run/**" - schedule: - interval: "cron" - cronjob: "0 4 * * *" - timezone: "Asia/Shanghai" - cooldown: - default-days: 30 - labels: - - "cleanup" - - "area/infra" - - - package-ecosystem: "npm" - directory: "/native/landlock-run" schedule: interval: "cron" cronjob: "0 4 * * *" diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index aa73dba8d9..6ff3f6f8ea 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -7,7 +7,7 @@ DeepSeek Harness is licensed under [BSD 3-Clause](LICENSE). It depends on the th This file lists **direct** dependencies declared by the workspace. It is generated from the workspace manifests by `scripts/gen-third-party-notices.ts`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and `scripts/gen-third-party-notices.spec.ts` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run `pnpm run verify-third-party-notices` for the standalone check. -The complete npm transitive closure, with exact pinned versions, is recorded in [`pnpm-lock.yaml`](pnpm-lock.yaml) — inspect it with `pnpm licenses list`. The Python closure is recorded in [`python/sdk/uv.lock`](python/sdk/uv.lock), and the Landlock launcher workspace keeps its own in [`native/landlock-run/pnpm-lock.yaml`](native/landlock-run/pnpm-lock.yaml). +The complete npm transitive closure, including the Landlock launcher workspace, is recorded with exact pinned versions in [`pnpm-lock.yaml`](pnpm-lock.yaml) — inspect it with `pnpm licenses list`. The Python closure is recorded separately in [`python/sdk/uv.lock`](python/sdk/uv.lock). ## Vendored source (`vendor/`) diff --git a/native/landlock-run/packages/entry/package.json b/native/landlock-run/packages/entry/package.json index f05e81f06b..56345b2847 100644 --- a/native/landlock-run/packages/entry/package.json +++ b/native/landlock-run/packages/entry/package.json @@ -3,6 +3,11 @@ "version": "0.0.1", "type": "module", "description": "Landlock self-restrict-then-exec launcher for sandboxing subprocesses on Linux: per-platform prebuilt static binaries plus the JS seam that resolves, probes, and speaks their CLI contract", + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-harness/deepseek-harness.git", + "directory": "native/landlock-run/packages/entry" + }, "main": "lib/index.js", "types": "lib/index.d.ts", "exports": { diff --git a/native/landlock-run/packages/linux-arm64/package.json b/native/landlock-run/packages/linux-arm64/package.json index 0067f77c8b..af5467cead 100644 --- a/native/landlock-run/packages/linux-arm64/package.json +++ b/native/landlock-run/packages/linux-arm64/package.json @@ -2,6 +2,11 @@ "name": "node-addon-landlock-run-linux-arm64", "version": "0.0.1", "description": "Prebuilt landlock-run Landlock launcher binary for linux-arm64 (static musl) — resolved as a file path by node-addon-landlock-run, never imported", + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-harness/deepseek-harness.git", + "directory": "native/landlock-run/packages/linux-arm64" + }, "os": [ "linux" ], diff --git a/native/landlock-run/packages/linux-x64/package.json b/native/landlock-run/packages/linux-x64/package.json index 8ea60b636c..375d05332a 100644 --- a/native/landlock-run/packages/linux-x64/package.json +++ b/native/landlock-run/packages/linux-x64/package.json @@ -2,6 +2,11 @@ "name": "node-addon-landlock-run-linux-x64", "version": "0.0.1", "description": "Prebuilt landlock-run Landlock launcher binary for linux-x64 (static musl) — resolved as a file path by node-addon-landlock-run, never imported", + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-harness/deepseek-harness.git", + "directory": "native/landlock-run/packages/linux-x64" + }, "os": [ "linux" ], diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 32e2096aa9..0a446d77f8 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -35,6 +35,7 @@ const publicLandlockPackages = new Set([ 'node-addon-landlock-run-linux-arm64', 'node-addon-landlock-run-linux-x64', ]) +const repositoryUrl = 'git+https://github.com/deepseek-harness/deepseek-harness.git' const localArtifactDirs = new Set(['node_modules']) const appPackageFiles: Readonly> = { @@ -63,6 +64,7 @@ interface PackageManifest { > files?: string[] publishConfig?: { access?: string } + repository?: { type?: string; url?: string; directory?: string } peerDependencies?: Record devDependencies?: Record } @@ -89,12 +91,12 @@ function packageDirs(base: string, depth: number): string[] { .filter(entry => entry.isDirectory()) .filter(entry => !localArtifactDirs.has(entry.name)) .filter(entry => existsSync(join(root, base, entry.name, 'package.json'))) - .map(entry => join(base, entry.name)) + .map(entry => `${base}/${entry.name}`) } return readdirSync(join(root, base), { withFileTypes: true }) .filter(entry => entry.isDirectory()) .filter(entry => !localArtifactDirs.has(entry.name)) - .flatMap(group => packageDirs(join(base, group.name), depth - 1)) + .flatMap(group => packageDirs(`${base}/${group.name}`, depth - 1)) } function workspaceManifests(): WorkspaceManifest[] { @@ -183,6 +185,12 @@ function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] { if (manifest.publishConfig?.access !== 'public') { errors.push(`${label}: published Landlock package must set publishConfig.access to "public"`) } + const expectedDirectory = dir + if (manifest.repository?.type !== 'git' + || manifest.repository.url !== repositoryUrl + || manifest.repository.directory !== expectedDirectory) { + errors.push(`${label}: published Landlock package repository must use ${repositoryUrl} with directory ${expectedDirectory} for trusted publishing`) + } } else if (manifest.private !== true) { errors.push(`${label}: package.json must set "private": true`) } diff --git a/scripts/gen-third-party-notices.ts b/scripts/gen-third-party-notices.ts index d7e0c26a5f..e56cce670a 100644 --- a/scripts/gen-third-party-notices.ts +++ b/scripts/gen-third-party-notices.ts @@ -191,8 +191,8 @@ export function virtualManifest(virtual: string, name: string): VirtualManifest function installedMetadata(name: string): { license: string; repo: string } { const override = OVERRIDES[name] let manifest: (Manifest & { license?: string; repository?: string | { url?: string }; homepage?: string }) | undefined - // The nested Landlock workspace installs into its own store, so a package - // only that workspace depends on is unreachable from the root one. + // Workspace-local link farms can expose a dependency that is not linked at + // the repository root; both are backed by the root workspace's lockfile. for (const store of ['node_modules', 'native/landlock-run/node_modules']) { const direct = resolve(root, store, name, 'package.json') if (existsSync(direct)) { @@ -208,7 +208,7 @@ function installedMetadata(name: string): { license: string; repo: string } { const rawRepo = typeof manifest?.repository === 'string' ? manifest.repository : manifest?.repository?.url ?? manifest?.homepage const repo = override?.repo ?? normalizeRepo(rawRepo) if (license === undefined || repo === undefined) { - throw new Error(`gen-third-party-notices: cannot resolve ${license === undefined ? 'license' : 'repository'} for ${name}; run \`pnpm install\` (or, for a Landlock-only dependency, \`pnpm --dir native/landlock-run install\`), or add an OVERRIDES entry.`) + throw new Error(`gen-third-party-notices: cannot resolve ${license === undefined ? 'license' : 'repository'} for ${name}; run \`pnpm install\`, or add an OVERRIDES entry.`) } return { license, repo } } @@ -544,7 +544,7 @@ DeepSeek Harness is licensed under [BSD 3-Clause](LICENSE). It depends on the th This file lists **direct** dependencies declared by the workspace. It is generated from the workspace manifests by \`scripts/gen-third-party-notices.ts\`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and \`scripts/gen-third-party-notices.spec.ts\` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run \`pnpm run verify-third-party-notices\` for the standalone check. -The complete npm transitive closure, with exact pinned versions, is recorded in [\`pnpm-lock.yaml\`](pnpm-lock.yaml) — inspect it with \`pnpm licenses list\`. The Python closure is recorded in [\`python/sdk/uv.lock\`](python/sdk/uv.lock), and the Landlock launcher workspace keeps its own in [\`native/landlock-run/pnpm-lock.yaml\`](native/landlock-run/pnpm-lock.yaml). +The complete npm transitive closure, including the Landlock launcher workspace, is recorded with exact pinned versions in [\`pnpm-lock.yaml\`](pnpm-lock.yaml) — inspect it with \`pnpm licenses list\`. The Python closure is recorded separately in [\`python/sdk/uv.lock\`](python/sdk/uv.lock). ## Vendored source (\`vendor/\`) From 32864c026cbfc7a36d875d1bee87b8c28bba0a78 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 6 Aug 2026 11:11:57 +0800 Subject: [PATCH 021/109] fix(clean): remove native build state (review round 3) --- scripts/clean.spec.ts | 4 +++- scripts/clean.ts | 5 +++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/scripts/clean.spec.ts b/scripts/clean.spec.ts index aada0667ef..c724453283 100644 --- a/scripts/clean.spec.ts +++ b/scripts/clean.spec.ts @@ -60,16 +60,18 @@ describe('RepositoryCleaner', () => { expect(existsSync(join(root, 'products/shell/lib'))).toBe(true) }) - it('removes the native Landlock entry output that emits directly to lib', async () => { + it('removes the native Landlock entry output and solution build info', async () => { const root = fixture() const entry = 'native/landlock-run/packages/entry' addProject(root, entry, 'lib') write(join(root, entry, 'lib/index.js')) + write(join(root, 'native/landlock-run/tsconfig.tsbuildinfo')) await new RepositoryCleaner(root).clean() expect(existsSync(join(root, entry, 'lib'))).toBe(false) expect(existsSync(join(root, entry, 'src/index.ts'))).toBe(true) + expect(existsSync(join(root, 'native/landlock-run/tsconfig.tsbuildinfo'))).toBe(false) }) it('refuses project outputs reached through a symlink outside the repository', async () => { diff --git a/scripts/clean.ts b/scripts/clean.ts index 1224fe8420..68e4ff4e71 100644 --- a/scripts/clean.ts +++ b/scripts/clean.ts @@ -72,6 +72,11 @@ export class RepositoryCleaner { for (const entry of await readdir(this.root, { withFileTypes: true })) { if (entry.isFile() && entry.name.endsWith('.tsbuildinfo')) targets.add(join(this.root, entry.name)) } + await this.addIfPresent( + targets, + join(this.root, 'native/landlock-run/tsconfig.tsbuildinfo'), + canonicalRoot, + ) // The root project-reference graph is the source of truth for live build targets. // Each emitting project declares lib/types as outDir; its parent lib also owns From 10c1d77a4f3842812293d138a4e447356149ab5b Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 6 Aug 2026 13:50:48 +0800 Subject: [PATCH 022/109] fix(landlock-run): address release review feedback --- .../implemented/feature/2026-07-06-sandbox.i18n.yaml | 4 ++-- .../notes/implemented/feature/2026-07-06-sandbox.md | 2 +- .../notes/implemented/feature/2026-07-06-sandbox.zh.md | 2 +- .github/workflows/landlock-run-release.yml | 10 ++++++++-- native/landlock-run/docs/release.md | 2 +- packages/bash/bash-sandbox/tests/landlock.e2e.ts | 6 +++--- .../sandbox/sandbox-local/tests/packed-install.e2e.ts | 6 ++++-- 7 files changed, 20 insertions(+), 12 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml index 5f8dfa4e65..b927bf9f72 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.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-06-sandbox.md -2026-07-06-sandbox.md: 69a3f1bd181bc06d9a176fa45b1e091991cfa682 -2026-07-06-sandbox.zh.md: eeca55b61da24df215f7a9b7ba8dbf9ab2387f20 +2026-07-06-sandbox.md: de00453eace87ef89e7e05bfe20e1ff956ee4d19 +2026-07-06-sandbox.zh.md: db84e9b3872fb5807720c75310c9ee58f2b9fdb7 diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.md index 69a3f1bd18..de00453eac 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.md @@ -128,7 +128,7 @@ Each phase gets its full design when picked up, validated against the code at th - **Second consumer** — `subagent-acp` optionally confines child agents (per-call policy; unconfined default — a child agent must write its own persistence). - **More environments** — an environment-coherent capability group example (e.g. bash+fs against one container). -- **Windows chain** — `PLATFORM_CHAINS.win32` is reserved and empty (fail-closed); filling it means a confinement runner from the AppContainer/restricted-token family, shipped from its own repository on the `node-addon-landlock-run` template, plus its profile dialect, denial signatures, and runner-failure rules. Wrapping the third-party landstrip runner instead was [considered and rejected](../../rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md) — not battle-tested enough for a security invariant. +- **Windows chain** — `PLATFORM_CHAINS.win32` is reserved and empty (fail-closed); filling it means a confinement runner from the AppContainer/restricted-token family, shipped from the main repository under `native/` following the `node-addon-landlock-run` template, plus its profile dialect, denial signatures, and runner-failure rules. Wrapping the third-party landstrip runner instead was [considered and rejected](../../rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md) — not battle-tested enough for a security invariant. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md index eeca55b61d..db84e9b387 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md @@ -128,7 +128,7 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层 - **第二个消费方**——`subagent-acp` 可选地约束子 agent(按调用策略;默认无约束——子 agent 必须写入自己的持久化)。 - **更多环境**——环境一致的能力组示例(如 bash+fs 对一个容器)。 -- **Windows 链**——`PLATFORM_CHAINS.win32` 保留为空(失败关闭);填充它意味着来自 AppContainer/restricted-token 家族的约束 runner,从其自己的仓库按 `node-addon-landlock-run` 模板交付,加上其 profile 方言、拒绝签名和 runner 失败规则。改为包装第三方 landstrip runner 的方案[经考虑后已驳回](../../rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md)——它所经受的实战检验还不足以承载安全不变式。 +- **Windows 链**——`PLATFORM_CHAINS.win32` 保留为空(失败关闭);填充它意味着来自 AppContainer/restricted-token 家族的约束 runner,由主仓库在 `native/` 下按 `node-addon-landlock-run` 模板交付,加上其 profile 方言、拒绝签名和 runner 失败规则。改为包装第三方 landstrip runner 的方案[经考虑后已驳回](../../rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md)——它所经受的实战检验还不足以承载安全不变式。 ## 曾考虑的替代方案 diff --git a/.github/workflows/landlock-run-release.yml b/.github/workflows/landlock-run-release.yml index dca6c9eed1..c69ebbe5ee 100644 --- a/.github/workflows/landlock-run-release.yml +++ b/.github/workflows/landlock-run-release.yml @@ -158,6 +158,14 @@ jobs: name: npm-tarballs path: native/landlock-run/dist/npm + - name: Configure npm token fallback + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + if [[ -n "$NPM_TOKEN" ]]; then + echo "NODE_AUTH_TOKEN=$NPM_TOKEN" >> "$GITHUB_ENV" + fi + - name: Publish tarballs run: | version="${GITHUB_REF#refs/tags/landlock-run-v}" @@ -166,5 +174,3 @@ jobs: while IFS= read -r tarball; do npm publish "dist/npm/${tarball}" --access public "${tag_args[@]}" done < dist/npm/publish-order.txt - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/native/landlock-run/docs/release.md b/native/landlock-run/docs/release.md index a95cffd47f..353a489c2c 100644 --- a/native/landlock-run/docs/release.md +++ b/native/landlock-run/docs/release.md @@ -45,7 +45,7 @@ Use the main repository's `Landlock Run Release` workflow so every binary is bui 2. Create and push the `landlock-run-vX.Y.Z` tag matching the package versions. 3. Run the same workflow from that tag with `publish=true`. -The workflow publishes only from the final packed tarballs, in `publish-order.txt` order (platform packages before the entry that optionally depends on them). It supports npm trusted publishing through GitHub OIDC; without it, provide an `NPM_TOKEN` secret in the `npm-publish` environment. Packages publish with `--access public`. +The workflow publishes only from the final packed tarballs, in `publish-order.txt` order (platform packages before the entry that optionally depends on them). A current-platform rehearsal can still query npm for metadata about an incompatible optional platform package; that package cannot supply the host launcher, which comes from the matching local tarball. Publishing every platform package before the entry ensures a public entry version never points ahead of its platform packages. The workflow supports npm trusted publishing through GitHub OIDC; without it, provide an `NPM_TOKEN` secret in the `npm-publish` environment. Packages publish with `--access public`. Manual local fallback (current platform's packages only) — always through `pack-release.mjs`, never `pnpm publish` directly (pnpm's pack path strips the launcher's executable bit; see [packaging.md](packaging.md)): diff --git a/packages/bash/bash-sandbox/tests/landlock.e2e.ts b/packages/bash/bash-sandbox/tests/landlock.e2e.ts index 0c5cfbe563..7579292fe1 100644 --- a/packages/bash/bash-sandbox/tests/landlock.e2e.ts +++ b/packages/bash/bash-sandbox/tests/landlock.e2e.ts @@ -13,14 +13,14 @@ import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' /** * KEYLESS consumer-integration proof: the REAL `LocalSandboxProvider` (bwrap - * rung forced off, so the npm-distributed `landlock-run` confines) underneath the + * rung forced off, so the workspace `landlock-run` launcher confines) underneath the * REAL `SandboxBashExecutor`, driven through the executor's public run/start * paths. Verifies the WORLD (files exist or don't) plus the stamped result * facts; the backend-only confinement proofs live with * `@deepseek-ai/dsh-sandbox-local`. * - * Self-skips when the running kernel does not enforce Landlock; the - * launcher binary itself arrives with `pnpm install` (`node-addon-landlock-run`). + * Self-skips when the running kernel does not enforce Landlock. CI builds the launcher from + * `native/landlock-run` before running this file. */ const probe = spawnSync(launcherPath(), ['--probe'], { timeout: 5_000, encoding: 'utf8' }) diff --git a/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts b/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts index caf0a32c68..612751e6da 100644 --- a/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts +++ b/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts @@ -9,8 +9,10 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest' /** * Keyless publish-path rehearsal. It packs the provider, its workspace peers, and the current * repository's Landlock entry/platform packages, then installs those exact tarballs in an external - * plain-Node consumer. No registry copy, tsx, path mapping, or workspace resolution can hide - * missing files, dependency errors, or lost executable modes. + * plain-Node consumer. The host launcher comes from the exact local tarballs, so no registry copy, + * tsx, path mapping, or workspace resolution can hide missing files, dependency errors, or lost + * executable modes. npm may still query registry metadata for an incompatible optional platform + * package that cannot supply the host launcher. * * The installed launcher must match the host architecture, remain executable, and either confine a * real process with bwrap disabled or fail closed on a non-enforcing kernel. Skips off Linux or From 22c70870742bd69590863c769a5beee684bf8e77 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 6 Aug 2026 14:41:17 +0800 Subject: [PATCH 023/109] fix(landlock-run): publish under deepseek scope --- .../feature/2026-07-06-sandbox.i18n.yaml | 4 ++-- .../implemented/feature/2026-07-06-sandbox.md | 2 +- .../feature/2026-07-06-sandbox.zh.md | 2 +- ...2-win32-in-process-folder-dialog.i18n.yaml | 4 ++-- ...26-08-02-win32-in-process-folder-dialog.md | 2 +- ...08-02-win32-in-process-folder-dialog.zh.md | 2 +- ...6-in-repository-landlock-release.i18n.yaml | 4 ++-- ...26-08-06-in-repository-landlock-release.md | 14 ++++++----- ...08-06-in-repository-landlock-release.zh.md | 14 ++++++----- .github/workflows/landlock-run-release.yml | 6 ++--- .github/workflows/landlock-run.yml | 4 ++-- AGENTS.md | 2 +- THIRD_PARTY_NOTICES.md | 2 +- native/landlock-run/README.i18n.yaml | 4 ++-- native/landlock-run/README.md | 12 +++++----- native/landlock-run/README.zh.md | 12 +++++----- native/landlock-run/docs/architecture.md | 6 ++--- native/landlock-run/docs/naming.md | 6 ++--- native/landlock-run/docs/packaging.md | 6 ++--- native/landlock-run/docs/release.md | 2 ++ native/landlock-run/docs/support-matrix.md | 4 ++-- native/landlock-run/package.json | 4 ++-- .../packages/entry/README.i18n.yaml | 4 ++-- native/landlock-run/packages/entry/README.md | 6 ++--- .../landlock-run/packages/entry/README.zh.md | 6 ++--- .../landlock-run/packages/entry/package.json | 6 ++--- .../landlock-run/packages/entry/src/index.ts | 4 ++-- native/landlock-run/packages/entry/src/main.c | 2 +- .../packages/linux-arm64/README.i18n.yaml | 4 ++-- .../packages/linux-arm64/README.md | 6 ++--- .../packages/linux-arm64/README.zh.md | 6 ++--- .../packages/linux-arm64/package.json | 4 ++-- .../packages/linux-x64/README.i18n.yaml | 4 ++-- .../landlock-run/packages/linux-x64/README.md | 6 ++--- .../packages/linux-x64/README.zh.md | 6 ++--- .../packages/linux-x64/package.json | 4 ++-- .../scripts/verify-packed-install.mjs | 6 ++--- native/landlock-run/test/entry.test.js | 4 ++-- native/landlock-run/test/launcher.test.js | 2 +- packages/bash/bash-sandbox/package.json | 2 +- .../bash/bash-sandbox/tests/landlock.e2e.ts | 2 +- .../tests/partial-landlock.spec.ts | 2 +- .../examples/agent-spine-demo/package.json | 2 +- .../tests/multi-project-sandbox.e2e.ts | 2 +- .../sandbox/sandbox-local/README.i18n.yaml | 4 ++-- packages/sandbox/sandbox-local/README.md | 2 +- packages/sandbox/sandbox-local/README.zh.md | 2 +- packages/sandbox/sandbox-local/package.json | 2 +- packages/sandbox/sandbox-local/src/index.ts | 2 +- .../sandbox/sandbox-local/src/profiles.ts | 2 +- .../sandbox-local/tests/landlock.e2e.ts | 2 +- .../sandbox/sandbox-local/tests/local.spec.ts | 2 +- .../sandbox-local/tests/packed-install.e2e.ts | 7 +++--- pnpm-lock.yaml | 24 +++++++++---------- scripts/check-workspace-constraints.ts | 13 ++++++---- scripts/gen-third-party-notices.ts | 8 +++---- tsconfig.base.json | 2 +- 57 files changed, 146 insertions(+), 134 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml index b927bf9f72..7294f47357 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.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-06-sandbox.md -2026-07-06-sandbox.md: de00453eace87ef89e7e05bfe20e1ff956ee4d19 -2026-07-06-sandbox.zh.md: db84e9b3872fb5807720c75310c9ee58f2b9fdb7 +2026-07-06-sandbox.md: 583b388815cd9b2b9cf94ce393839169ce3ffac3 +2026-07-06-sandbox.zh.md: e435b671a42ca5c3ea4f6800bf91d6e006da35d3 diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.md index de00453eac..583b388815 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.md @@ -128,7 +128,7 @@ Each phase gets its full design when picked up, validated against the code at th - **Second consumer** — `subagent-acp` optionally confines child agents (per-call policy; unconfined default — a child agent must write its own persistence). - **More environments** — an environment-coherent capability group example (e.g. bash+fs against one container). -- **Windows chain** — `PLATFORM_CHAINS.win32` is reserved and empty (fail-closed); filling it means a confinement runner from the AppContainer/restricted-token family, shipped from the main repository under `native/` following the `node-addon-landlock-run` template, plus its profile dialect, denial signatures, and runner-failure rules. Wrapping the third-party landstrip runner instead was [considered and rejected](../../rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md) — not battle-tested enough for a security invariant. +- **Windows chain** — `PLATFORM_CHAINS.win32` is reserved and empty (fail-closed); filling it means a confinement runner from the AppContainer/restricted-token family, shipped from the main repository under `native/` following the `@deepseek-ai/node-addon-landlock-run` template, plus its profile dialect, denial signatures, and runner-failure rules. Wrapping the third-party landstrip runner instead was [considered and rejected](../../rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md) — not battle-tested enough for a security invariant. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md index db84e9b387..e435b671a4 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md @@ -128,7 +128,7 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层 - **第二个消费方**——`subagent-acp` 可选地约束子 agent(按调用策略;默认无约束——子 agent 必须写入自己的持久化)。 - **更多环境**——环境一致的能力组示例(如 bash+fs 对一个容器)。 -- **Windows 链**——`PLATFORM_CHAINS.win32` 保留为空(失败关闭);填充它意味着来自 AppContainer/restricted-token 家族的约束 runner,由主仓库在 `native/` 下按 `node-addon-landlock-run` 模板交付,加上其 profile 方言、拒绝签名和 runner 失败规则。改为包装第三方 landstrip runner 的方案[经考虑后已驳回](../../rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md)——它所经受的实战检验还不足以承载安全不变式。 +- **Windows 链**——`PLATFORM_CHAINS.win32` 保留为空(失败关闭);填充它意味着来自 AppContainer/restricted-token 家族的约束 runner,由主仓库在 `native/` 下按 `@deepseek-ai/node-addon-landlock-run` 模板交付,加上其 profile 方言、拒绝签名和 runner 失败规则。改为包装第三方 landstrip runner 的方案[经考虑后已驳回](../../rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md)——它所经受的实战检验还不足以承载安全不变式。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.i18n.yaml b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.i18n.yaml index 2ec7925a3e..b98c2bee56 100644 --- a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.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-08-02-win32-in-process-folder-dialog.md -2026-08-02-win32-in-process-folder-dialog.md: 91a1ed0d7b1c1938a5e038ce36f1ca90bf3c9e82 -2026-08-02-win32-in-process-folder-dialog.zh.md: 6b90dc1c5fa0042b3e2bcbea8ed554f1f0ea2acf +2026-08-02-win32-in-process-folder-dialog.md: 5389293605169ce5ca269a127de5f609b8b7dd11 +2026-08-02-win32-in-process-folder-dialog.zh.md: ef81bc1f65b2859de1eb60f74f79e4869da4746b diff --git a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.md b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.md index 91a1ed0d7b..5389293605 100644 --- a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.md +++ b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.md @@ -14,7 +14,7 @@ The Windows directory picker's primary tier was a spawned PowerShell script arou ## Alternatives considered -- **A prebuilt native helper (`native/` family like `node-addon-landlock-run`).** Rejected: a mirror repository, an npm package family, MSVC provisioning, and a release handoff — all to ship ~150 lines of C the repository cannot exercise on CI (no real-Windows lane); koffi delivers the same COM surface with zero new supply chain. +- **A prebuilt native helper (`native/` family like `@deepseek-ai/node-addon-landlock-run`).** Rejected: another npm package family, MSVC provisioning, and a Windows build/release lane — all to ship ~150 lines of C the repository cannot currently exercise on CI (no real-Windows lane); koffi delivers the same COM surface with zero new supply chain. - **An N-API in-process addon.** Rejected for the same CI/toolchain reasons plus owned C++ for STA threading and message pumping that a child process + koffi express in TypeScript. - **Keep PowerShell primary and probe versions.** Rejected: the picker stays hostage to shell packaging (6 vs 7, Store aliases, profiles), and 5.1's legacy dialog remains the floor wherever pwsh is absent; the fallback-trigger widening alone was accepted into the fallback tier instead. - **Blocking the main thread for the modal call.** Rejected outright: the web host must keep serving RPC while the dialog is open. diff --git a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.zh.md b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.zh.md index 6b90dc1c5f..ef81bc1f65 100644 --- a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.zh.md +++ b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.zh.md @@ -14,7 +14,7 @@ Windows 目录选择器的主层此前是围绕 WinForms `FolderBrowserDialog` ## 考虑过的替代方案 -- **预编译原生助手(`native/` 家族,如 `node-addon-landlock-run`)。** 否决:镜像仓库、npm 包家族、MSVC 供给和发布交接——只为交付约 150 行 CI 无法执行的 C(没有真 Windows 通道);koffi 以零新增供应链提供同一 COM 面。 +- **预编译原生助手(`native/` 家族,如 `@deepseek-ai/node-addon-landlock-run`)。** 否决:再增加一个 npm 包家族、MSVC 供给和 Windows 构建/发布通道——只为交付约 150 行目前无法在 CI 中执行的 C(现有 CI 没有真 Windows 通道);koffi 以零新增供应链提供同一 COM 面。 - **N-API 进程内插件。** 否决:同样的 CI/工具链原因,另加需要自有 C++ 处理 STA 线程与消息泵,而子进程 + koffi 用 TypeScript 就能表达。 - **保留 PowerShell 为主层并探测版本。** 否决:选择器仍被 shell 打包形态挟持(6 与 7、Store 别名、profile),且没有 pwsh 的机器地板仍是 5.1 的旧版对话框;仅把回退触发条件的拓宽吸收进回退层。 - **在主线程上阻塞模态调用。** 直接否决:对话框打开期间 web 宿主必须继续服务 RPC。 diff --git a/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.i18n.yaml b/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.i18n.yaml index 3ce0e0d5e1..2a4a389174 100644 --- a/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.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-06-in-repository-landlock-release.md -2026-08-06-in-repository-landlock-release.md: f682078250adde8d56a4270e9d01ce4b1cd1bee9 -2026-08-06-in-repository-landlock-release.zh.md: 4950d80d87afd18c5605f4f5bca56b8d85564fc2 +2026-08-06-in-repository-landlock-release.md: 3ae9e9c3c50a1d0202a345e419cb2b7079e29ffa +2026-08-06-in-repository-landlock-release.zh.md: 9f2233f6ae95620d7221f83648b5dc9b4cf8c7d2 diff --git a/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.md b/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.md index f682078250..3ae9e9c3c5 100644 --- a/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.md +++ b/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.md @@ -6,17 +6,19 @@ English | [中文](2026-08-06-in-repository-landlock-release.zh.md) ## Problem -The `node-addon-landlock-run` source already lives beside its DeepSeek Harness consumers under `native/landlock-run`, but it previously kept a separate pnpm workspace and lockfile and depended on a standalone repository for npm publication. Harness packages consumed a fixed registry version, so one pull request could change the launcher contract and its consumer without testing those changes together. The source repository's native workflow could rehearse the package, but it did not publish the artifact it tested. +The `@deepseek-ai/node-addon-landlock-run` source already lives beside its DeepSeek Harness consumers under `native/landlock-run`, but it previously kept a separate pnpm workspace and lockfile and depended on a standalone repository for npm publication. Harness packages consumed a fixed registry version, so one pull request could change the launcher contract and its consumer without testing those changes together. The source repository's native workflow could rehearse the package, but it did not publish the artifact it tested. The mirror also duplicated release coordination: export the source, update another lockfile, run another release workflow, publish the native family, then return to this repository to bump registry dependencies. That split made source-to-binary provenance, rollback, and security-fix coordination harder without changing what npm users actually needed. +The existing unscoped npm names are owned by the standalone publisher account rather than the `@deepseek-ai` organization. Moving only the workflow would therefore leave publication dependent on a personal credential outside the repository's release ownership. + The consolidation must preserve platform selection. The public distribution is deliberately one JavaScript entry package plus separate Linux x64 and arm64 binary packages; merging repository ownership does not imply putting every binary into one tarball or publishing every DeepSeek Harness package at the launcher version. ## Decision -`native/landlock-run` and `native/landlock-run/packages/*` belong to the repository's root pnpm workspace and use the root `pnpm-lock.yaml`. Harness consumers declare `node-addon-landlock-run` with `workspace:*`, so development, type checking, builds, and pull-request tests resolve the entry package from the same checkout. The root TypeScript project graph builds that entry package before consumers, and the repository cleaner owns its direct `lib/` output. +`native/landlock-run` and `native/landlock-run/packages/*` belong to the repository's root pnpm workspace and use the root `pnpm-lock.yaml`. Harness consumers declare `@deepseek-ai/node-addon-landlock-run` with `workspace:*`, so development, type checking, builds, and pull-request tests resolve the entry package from the same checkout. The root TypeScript project graph builds that entry package before consumers, and the repository cleaner owns its direct `lib/` output. -The public npm boundary remains three packages with one launcher-family version: `node-addon-landlock-run`, `node-addon-landlock-run-linux-x64`, and `node-addon-landlock-run-linux-arm64`. The entry package retains both platform packages as `optionalDependencies`; their `os` and `cpu` manifest fields let npm install only the compatible package. Repository constraints allow public publication only for those three names, require `publishConfig.access: public`, and require their versions to match the private launcher workspace root. Other repository workspaces remain private under the existing constraint. +The public npm boundary is three organization-owned packages with one launcher-family version: `@deepseek-ai/node-addon-landlock-run`, `@deepseek-ai/node-addon-landlock-run-linux-x64`, and `@deepseek-ai/node-addon-landlock-run-linux-arm64`. The entry package retains both platform packages as `optionalDependencies`; their `os` and `cpu` manifest fields let npm install only the compatible package. Repository constraints allow public publication only for those three names, require `publishConfig.access: public`, and require their versions to match the private launcher workspace root. The former unscoped names are not release targets of this repository; other repository workspaces remain private under the existing constraint. The main repository owns both native CI and publication. `Landlock Run` runs for relevant pull requests and `master` pushes and builds each platform on its matching native runner. The manually dispatched `Landlock Run Release` workflow builds both platform binaries, transfers them as workflow artifacts, assembles and verifies the complete package family, packs immutable npm tarballs, installs and exercises those tarballs, and only then permits the protected publish job. Platform tarballs publish before the entry tarball that optionally depends on them. Publication uses `landlock-run-vX.Y.Z` tags so launcher releases cannot collide with other release families in the monorepo; prereleases use the npm `next` dist-tag. @@ -26,17 +28,17 @@ The sandbox packed-install rehearsal no longer permits the npm registry to suppl - **Keep the standalone repository as a release mirror** — rejected because it preserves the split lockfiles, source export, stale-registry test window, and cross-repository release sequence after the source of record has already moved here. - **Publish one npm package containing every platform binary** — rejected because users would download binaries they cannot run and npm could no longer use package-level `os`/`cpu` filtering. Repository ownership and npm package layout are separate choices. -- **Give the launcher the root DeepSeek Harness version and publish the complete monorepo recursively** — rejected because this change owns one three-package public family, not the independent `@deepseek-ai/*` baseline. The [artifact-first npm baseline proposal](../../proposed/process/2026-08-04-artifact-first-npm-baseline-publication.md) explicitly keeps native workspaces outside its target set. +- **Give the launcher the root DeepSeek Harness version and publish the complete monorepo recursively** — rejected because this change owns one three-package public family, not the independent `@deepseek-ai/dsh-*` baseline. The [artifact-first npm baseline proposal](../../proposed/process/2026-08-04-artifact-first-npm-baseline-publication.md) explicitly keeps native workspaces outside its target set. - **Cross-compile both binaries in one release job** — rejected because the checked-in package matrix already assigns each architecture a native GitHub runner and avoids adding a cross-toolchain trust surface. ## Consequences Launcher protocol, TypeScript entry code, native source, harness consumption, and publish-path tests can change in one pull request and resolve from one lockfile. A release tag now identifies the source, consumer integration, build instructions, and tarballs tested by the main repository. The standalone mirror is no longer part of the release path and can be archived after the first successful in-repository publication. -npm consumers keep the same install command and package names. A supported Linux host downloads the entry package and its matching architecture package; the other architecture package is skipped. An unsupported host receives no platform binary and follows the existing deterministic fail-closed probe path. +npm consumers install `@deepseek-ai/node-addon-landlock-run`; the old unscoped package names are not silently redirected. A supported Linux host downloads the scoped entry package and its matching architecture package; the other architecture package is skipped. An unsupported host receives no platform binary and follows the existing deterministic fail-closed probe path. The implementation touches more files than a dependency-line edit because the repository must also own workspace constraints, TypeScript build order, cleanup, CI triggers, release tags, lockfile generation, packed-install provenance, release documentation, and generated notices. The behavioral boundary stays narrow: it changes only the Landlock package family and its three direct workspace consumers, not the version or publication state of other DeepSeek Harness packages. -The main repository's `npm-publish` environment must authorize npm trusted publishing or provide `NPM_TOKEN`; moving workflow code cannot configure those external settings. npm still publishes packages sequentially and offers no cross-package transaction, so a failed publish can leave a partial version. Because npm rejects an already-published name and version, an operator must inspect the registry and publish only the missing tarballs rather than rerunning the workflow unchanged. Linux x64 and arm64 runners remain the authoritative binary and real-kernel checks; a macOS checkout can verify the entry package and unsupported-platform behavior but cannot replace those jobs. +The first scoped release must use an `@deepseek-ai` organization token through the `npm-publish` environment's `NPM_TOKEN`, because npm cannot configure trusted publishing until a package exists. After bootstrap, all three packages must authorize this repository's release workflow before the fallback token can be removed. npm still publishes packages sequentially and offers no cross-package transaction, so a failed publish can leave a partial version. Because npm rejects an already-published name and version, an operator must inspect the registry and publish only the missing tarballs rather than rerunning the workflow unchanged. Linux x64 and arm64 runners remain the authoritative binary and real-kernel checks; a macOS checkout can verify the entry package and unsupported-platform behavior but cannot replace those jobs. This note supersedes only the release-mirror and registry-pinned source-development statements in the [sandbox Agent Note](../feature/2026-07-06-sandbox.md); that note continues to own sandbox behavior, runner selection, and enforcement semantics. diff --git a/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.zh.md b/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.zh.md index 4950d80d87..9f2233f6ae 100644 --- a/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.zh.md +++ b/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.zh.md @@ -6,17 +6,19 @@ Status: implemented ## 问题 -`node-addon-landlock-run` 源码已经与其 DeepSeek Harness 消费方一同位于 `native/landlock-run` 下,但此前仍保留独立的 pnpm workspace 和锁文件,并依赖一个独立仓库发布到 npm。Harness 包使用 npm 注册表中的固定版本,因此同一个 PR(Pull Request)可以同时修改启动器契约及其消费方,却无法一起测试这些改动。源码仓库的原生工作流可以演练打包流程,但不会发布它实际测试过的产物。 +`@deepseek-ai/node-addon-landlock-run` 源码已经与其 DeepSeek Harness 消费方一同位于 `native/landlock-run` 下,但此前仍保留独立的 pnpm workspace 和锁文件,并依赖一个独立仓库发布到 npm。Harness 包使用 npm 注册表中的固定版本,因此同一个 PR(Pull Request)可以同时修改启动器契约及其消费方,却无法一起测试这些改动。源码仓库的原生工作流可以演练打包流程,但不会发布它实际测试过的产物。 发布镜像还造成重复的发布协调工作:导出源码、更新另一份锁文件、运行另一套发布工作流、发布原生包家族,然后回到本仓库更新注册表依赖。npm 用户的实际需求并未改变,这种拆分却增加了从源码到二进制的溯源、回滚和安全修复协调难度。 +现有的非 scoped npm 包名归独立发布账号所有,而不属于 `@deepseek-ai` 组织。因此,仅迁移工作流仍会让发布依赖仓库发布归属之外的个人凭证。 + 此次整合必须保留平台选择机制。公开分发有意采用一个 JavaScript 入口包,并为 Linux x64 和 arm64 分别提供二进制包;合并仓库归属并不意味着要把所有二进制文件放进同一个 tarball,也不意味着要按照启动器版本发布所有 DeepSeek Harness 包。 ## 决策 -`native/landlock-run` 和 `native/landlock-run/packages/*` 属于仓库根 pnpm workspace,并使用根 `pnpm-lock.yaml`。Harness 消费方将 `node-addon-landlock-run` 声明为 `workspace:*`,因此开发、类型检查、构建和 PR 测试都会从同一个 checkout 解析入口包。根 TypeScript 项目图会先构建该入口包,再构建消费方;仓库清理器负责清理其直接生成的 `lib/` 输出目录。 +`native/landlock-run` 和 `native/landlock-run/packages/*` 属于仓库根 pnpm workspace,并使用根 `pnpm-lock.yaml`。Harness 消费方将 `@deepseek-ai/node-addon-landlock-run` 声明为 `workspace:*`,因此开发、类型检查、构建和 PR 测试都会从同一个 checkout 解析入口包。根 TypeScript 项目图会先构建该入口包,再构建消费方;仓库清理器负责清理其直接生成的 `lib/` 输出目录。 -公开 npm 分发边界仍由 3 个包组成,它们共用一个启动器包家族版本:`node-addon-landlock-run`、`node-addon-landlock-run-linux-x64` 和 `node-addon-landlock-run-linux-arm64`。入口包继续通过 `optionalDependencies` 声明两个平台包;它们在 manifest(元数据清单)中的 `os` 和 `cpu` 字段让 npm 只安装兼容的包。仓库约束只允许公开发布这 3 个包名,要求设置 `publishConfig.access: public`,并要求其版本与私有启动器 workspace 根包一致。仓库中的其他 workspace 仍受现有约束保护,保持私有状态。 +公开 npm 分发边界由 3 个归组织所有的包组成,它们共用一个启动器包家族版本:`@deepseek-ai/node-addon-landlock-run`、`@deepseek-ai/node-addon-landlock-run-linux-x64` 和 `@deepseek-ai/node-addon-landlock-run-linux-arm64`。入口包继续通过 `optionalDependencies` 声明两个平台包;它们在 manifest(元数据清单)中的 `os` 和 `cpu` 字段让 npm 只安装兼容的包。仓库约束只允许公开发布这 3 个包名,要求设置 `publishConfig.access: public`,并要求其版本与私有启动器 workspace 根包一致。原先的非 scoped 包名不属于本仓库的发布目标;仓库中的其他 workspace 仍受现有约束保护,保持私有状态。 主仓库同时负责原生 CI 和发布。`Landlock Run` 会为相关 PR 和 `master` 推送运行,并在各自匹配的原生 runner 上构建每个平台包。手动触发的 `Landlock Run Release` 工作流会构建两个平台的二进制文件,将其作为工作流产物传递,组装并验证完整的包家族,打包出内容不可变的 npm tarball,安装并实际运行这些 tarball,之后才允许受保护的发布作业执行。发布顺序是平台 tarball 在前,最后发布将它们列为可选依赖的入口 tarball。发布使用 `landlock-run-vX.Y.Z` tag,避免启动器版本与 monorepo 中其他发布家族发生冲突;预发布版本使用 npm 的 `next` dist-tag。 @@ -26,17 +28,17 @@ Status: implemented - **保留独立仓库作为发布镜像**:不予采纳,因为在权威源码已经迁入本仓库后,这仍会保留拆分的锁文件、源码导出、测试使用陈旧注册表版本的时间窗,以及跨仓库发布序列。 - **发布一个包含所有平台二进制文件的 npm 包**:不予采纳,因为用户会下载无法在其主机上运行的二进制文件,而且 npm 无法再利用包级 `os`/`cpu` 筛选。仓库归属与 npm 包布局是两个彼此独立的选择。 -- **让启动器使用 DeepSeek Harness 根版本,并递归发布整个 monorepo**:不予采纳,因为本次改动负责的是一个由 3 个包组成的公开包家族,而不是独立的 `@deepseek-ai/*` 基线。[产物优先的 npm 基线提案](../../proposed/process/2026-08-04-artifact-first-npm-baseline-publication.md)明确将原生 workspace 排除在其目标集合之外。 +- **让启动器使用 DeepSeek Harness 根版本,并递归发布整个 monorepo**:不予采纳,因为本次改动负责的是一个由 3 个包组成的公开包家族,而不是独立的 `@deepseek-ai/dsh-*` 基线。[产物优先的 npm 基线提案](../../proposed/process/2026-08-04-artifact-first-npm-baseline-publication.md)明确将原生 workspace 排除在其目标集合之外。 - **在一个发布作业中交叉编译两个二进制文件**:不予采纳,因为仓库内已提交的包矩阵已经为每种架构分配了原生 GitHub runner,无需再把交叉工具链纳入信任边界。 ## 后果 同一个 PR 可以同时修改启动器协议、TypeScript 入口代码、原生源码、harness 消费方式和发布路径测试,并从同一份锁文件解析这些内容。发布 tag 现在标识源码、消费方集成、构建指令,以及主仓库测试过的 tarball。第一次成功从本仓库发布后,独立镜像便不再属于发布路径,可以归档。 -npm 消费方继续使用相同的安装命令和包名。受支持的 Linux 主机会下载入口包及与其架构匹配的包,并跳过另一架构的包。不受支持的主机不会收到平台二进制文件,并继续沿用现有的确定性失败闭合探测路径。 +npm 消费方改为安装 `@deepseek-ai/node-addon-landlock-run`;原先的非 scoped 包名不会被静默重定向。受支持的 Linux 主机会下载 scoped 入口包及与其架构匹配的包,并跳过另一架构的包。不受支持的主机不会收到平台二进制文件,并继续沿用现有的确定性失败闭合探测路径。 实现涉及的文件比只修改一行依赖更多,因为仓库还必须负责 workspace 约束、TypeScript 构建顺序、清理、CI 触发条件、发布 tag、锁文件生成、打包安装来源证明、发布文档和生成的第三方声明。行为边界仍然很窄:此次改动只影响 Landlock 包家族及其 3 个直接 workspace 消费方,不改变其他 DeepSeek Harness 包的版本或发布状态。 -主仓库的 `npm-publish` 环境必须授权 npm trusted publishing,或提供 `NPM_TOKEN`;只迁移工作流代码无法配置这些外部设置。npm 仍会按顺序发布各个包,且不提供跨包事务,因此发布失败可能留下只完成了一部分的版本。由于 npm 会拒绝已经发布的同名同版本包,操作人员必须检查注册表并只发布缺失的 tarball,而不能原样重新运行工作流。Linux x64 和 arm64 runner 仍提供权威的二进制构建与真实内核检查;macOS checkout 可以验证入口包和不受支持平台上的行为,但不能取代这些作业。 +第一次发布 scoped 包时,必须通过 `npm-publish` 环境的 `NPM_TOKEN` 使用 `@deepseek-ai` 组织 token,因为 npm 只有在包已经存在后才能配置 trusted publishing。完成 bootstrap 后,必须让 3 个包都授权本仓库的发布工作流,才能移除后备 token。npm 仍会按顺序发布各个包,且不提供跨包事务,因此发布失败可能留下只完成了一部分的版本。由于 npm 会拒绝已经发布的同名同版本包,操作人员必须检查注册表并只发布缺失的 tarball,而不能原样重新运行工作流。Linux x64 和 arm64 runner 仍提供权威的二进制构建与真实内核检查;macOS checkout 可以验证入口包和不受支持平台上的行为,但不能取代这些作业。 本说明仅取代[沙箱 Agent Note](../feature/2026-07-06-sandbox.md)中有关发布镜像和开发源码时依赖注册表固定版本的表述;该 Agent Note 仍负责沙箱行为、runner 选择和强制执行语义。 diff --git a/.github/workflows/landlock-run-release.yml b/.github/workflows/landlock-run-release.yml index c69ebbe5ee..7d78e98bc6 100644 --- a/.github/workflows/landlock-run-release.yml +++ b/.github/workflows/landlock-run-release.yml @@ -1,4 +1,4 @@ -# Build and publish the node-addon-landlock-run package family from the +# Build and publish the @deepseek-ai/node-addon-landlock-run package family from the # harness source of record. Rehearsal and publication consume the same packed # tarballs; each native binary is built on its matching architecture. name: Landlock Run Release @@ -58,7 +58,7 @@ jobs: cache-dependency-path: pnpm-lock.yaml - name: Install dependencies - run: pnpm install --filter node-addon-landlock-run-workspace... --frozen-lockfile + run: pnpm install --filter @deepseek-ai/node-addon-landlock-run-workspace... --frozen-lockfile - name: Install musl toolchain run: | @@ -97,7 +97,7 @@ jobs: cache-dependency-path: pnpm-lock.yaml - name: Install dependencies - run: pnpm install --filter node-addon-landlock-run-workspace... --frozen-lockfile + run: pnpm install --filter @deepseek-ai/node-addon-landlock-run-workspace... --frozen-lockfile - name: Build TypeScript run: pnpm build:ts diff --git a/.github/workflows/landlock-run.yml b/.github/workflows/landlock-run.yml index 391e1eeae1..6379c8cdc1 100644 --- a/.github/workflows/landlock-run.yml +++ b/.github/workflows/landlock-run.yml @@ -73,7 +73,7 @@ jobs: cache-dependency-path: pnpm-lock.yaml - name: Install dependencies - run: pnpm install --filter node-addon-landlock-run-workspace... --frozen-lockfile + run: pnpm install --filter @deepseek-ai/node-addon-landlock-run-workspace... --frozen-lockfile - name: Install musl toolchain run: | @@ -124,7 +124,7 @@ jobs: cache-dependency-path: pnpm-lock.yaml - name: Install dependencies - run: pnpm install --filter node-addon-landlock-run-workspace... --frozen-lockfile + run: pnpm install --filter @deepseek-ai/node-addon-landlock-run-workspace... --frozen-lockfile - name: Build TypeScript run: pnpm build:ts diff --git a/AGENTS.md b/AGENTS.md index a42f39084a..79d35f97fe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,7 +39,7 @@ packages/ @deepseek-ai/dsh- workspaces at packages/// support/ dev/test infrastructure util/ zero-dependency utilities python/ Python SDK and bundled runtime (see python/README.md) -native/ node-addon-landlock-run source of record (see native/README.md) +native/ @deepseek-ai/node-addon-landlock-run source of record (see native/README.md) examples/ Runnable cordis.yml leaves over packages/examples bundles (see examples/AGENTS.md) .agents/ Agent workflows and Agent Notes (`notes/`) docs/ architecture, generated catalogs, postmortems, cookbook (see docs/AGENTS.md) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 6ff3f6f8ea..e6907730fe 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -172,4 +172,4 @@ Direct dependencies of the `pyproject.toml` manifests, plus `uv` as the developm ## First-party native packages -`node-addon-landlock-run` (and its platform packages) is built and released from this repository under BSD 3-Clause. It is listed here for completeness; it is first-party, not third-party. +`@deepseek-ai/node-addon-landlock-run` (and its platform packages) is built and released from this repository under BSD 3-Clause. It is listed here for completeness; it is first-party, not third-party. diff --git a/native/landlock-run/README.i18n.yaml b/native/landlock-run/README.i18n.yaml index bdcf985216..c204d571cf 100644 --- a/native/landlock-run/README.i18n.yaml +++ b/native/landlock-run/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 native/landlock-run/README.md -README.md: 19cc18830b90609f648cfb2ce1ee509ad9fe381b -README.zh.md: 5d3c1c2cd692bb87d5a759a0a6f9628a3f065863 +README.md: fcb8e8249e6728d925fd08c938a780b155d3d4ac +README.zh.md: 8206ab8074d8d80fb9b11cff436bbd10e6dc34dd diff --git a/native/landlock-run/README.md b/native/landlock-run/README.md index 19cc18830b..fcb8e8249e 100644 --- a/native/landlock-run/README.md +++ b/native/landlock-run/README.md @@ -1,4 +1,4 @@ -# node-addon-landlock-run +# @deepseek-ai/node-addon-landlock-run English | [中文](README.zh.md) @@ -9,15 +9,15 @@ The first tool is **`landlock-run`** — a self-restrict-then-exec [Landlock](ht ## Install ```sh -npm install node-addon-landlock-run +npm install @deepseek-ai/node-addon-landlock-run ``` Published packages use an entry package plus platform optional packages: ```text -node-addon-landlock-run -node-addon-landlock-run-linux-x64 -node-addon-landlock-run-linux-arm64 +@deepseek-ai/node-addon-landlock-run +@deepseek-ai/node-addon-landlock-run-linux-x64 +@deepseek-ai/node-addon-landlock-run-linux-arm64 ``` npm's `os`/`cpu` fields make installers fetch only the matching platform package. There is no install-time build fallback on purpose: on a host without a platform package the resolved path never exists, the probe reports `unusable`, and the consumer falls closed. @@ -25,7 +25,7 @@ npm's `os`/`cpu` fields make installers fetch only the matching platform package ## Usage ```js -import { grantArgs, launcherPath, probe } from 'node-addon-landlock-run'; +import { grantArgs, launcherPath, probe } from '@deepseek-ai/node-addon-landlock-run'; const launcher = launcherPath(); if (probe(launcher) !== 'unusable') { diff --git a/native/landlock-run/README.zh.md b/native/landlock-run/README.zh.md index 5d3c1c2cd6..8206ab8074 100644 --- a/native/landlock-run/README.zh.md +++ b/native/landlock-run/README.zh.md @@ -1,4 +1,4 @@ -# node-addon-landlock-run +# @deepseek-ai/node-addon-landlock-run [English](README.md) | 中文 @@ -9,15 +9,15 @@ ## 安装 ```sh -npm install node-addon-landlock-run +npm install @deepseek-ai/node-addon-landlock-run ``` 已发布包由一个入口包和可选平台包组成: ```text -node-addon-landlock-run -node-addon-landlock-run-linux-x64 -node-addon-landlock-run-linux-arm64 +@deepseek-ai/node-addon-landlock-run +@deepseek-ai/node-addon-landlock-run-linux-x64 +@deepseek-ai/node-addon-landlock-run-linux-arm64 ``` npm 的 `os`/`cpu` 字段使安装器只拉取匹配的平台包。系统有意不提供安装时构建回退:在没有对应平台包的宿主上,解析后的路径绝不存在,探测会报告 `unusable`,消费方以失败闭合方式处理。 @@ -25,7 +25,7 @@ npm 的 `os`/`cpu` 字段使安装器只拉取匹配的平台包。系统有意 ## 用法 ```js -import { grantArgs, launcherPath, probe } from 'node-addon-landlock-run'; +import { grantArgs, launcherPath, probe } from '@deepseek-ai/node-addon-landlock-run'; const launcher = launcherPath(); if (probe(launcher) !== 'unusable') { diff --git a/native/landlock-run/docs/architecture.md b/native/landlock-run/docs/architecture.md index e6974f4e50..b462b40635 100644 --- a/native/landlock-run/docs/architecture.md +++ b/native/landlock-run/docs/architecture.md @@ -6,8 +6,8 @@ This repository owns confinement *mechanism*, not policy: consumers (agent harne The family is one entry package plus per-platform binary packages: -- **Entry package** (`node-addon-landlock-run`): ESM JavaScript. Owns the tool's CLI contract — path resolution (`launcherPath`), the functional probe (`probe`), grant-argv construction (`grantArgs`), and the contract constants. Ships the C source in its tarball for auditability. Lists every platform package as an `optionalDependency`. -- **Platform packages** (`node-addon-landlock-run-linux-{x64,arm64}`): one prebuilt static binary under `bin/`, a `prebuilds.json` declaring it, and no JavaScript at all. npm's `os`/`cpu` fields select the matching one at install time; the entry package resolves it to a file path — there is nothing to import. +- **Entry package** (`@deepseek-ai/node-addon-landlock-run`): ESM JavaScript. Owns the tool's CLI contract — path resolution (`launcherPath`), the functional probe (`probe`), grant-argv construction (`grantArgs`), and the contract constants. Ships the C source in its tarball for auditability. Lists every platform package as an `optionalDependency`. +- **Platform packages** (`@deepseek-ai/node-addon-landlock-run-linux-{x64,arm64}`): one prebuilt static binary under `bin/`, a `prebuilds.json` declaring it, and no JavaScript at all. npm's `os`/`cpu` fields select the matching one at install time; the entry package resolves it to a file path — there is nothing to import. Because the contract parser and the binary version together in one family, probe-parsing drift against the binary is structurally impossible — the failure mode the split exists to prevent. @@ -15,7 +15,7 @@ There is no shared loader package: platform packages have nothing to load. If a ## Resolution and availability -`launcherPath()` resolves `node-addon-landlock-run--` and returns `/bin/landlock-run`. When the package is not resolvable it returns a deterministic fallback path inside the entry package's own `node_modules` that simply never exists. Existence is deliberately unchecked either way: `probe()` is the single availability signal, and a missing binary probes `unusable` exactly like an unenforcing kernel. Consumers get one degradation path, not two. +`launcherPath()` resolves `@deepseek-ai/node-addon-landlock-run--` and returns `/bin/landlock-run`. When the package is not resolvable it returns a deterministic fallback path inside the entry package's own `node_modules` that simply never exists. Existence is deliberately unchecked either way: `probe()` is the single availability signal, and a missing binary probes `unusable` exactly like an unenforcing kernel. Consumers get one degradation path, not two. The probe is functional — the launcher builds and enforces a real maximal ruleset in a short-lived child — because version checks would miss a kernel that has the syscalls but refuses enforcement. diff --git a/native/landlock-run/docs/naming.md b/native/landlock-run/docs/naming.md index 9de9f0b95f..a1f2665654 100644 --- a/native/landlock-run/docs/naming.md +++ b/native/landlock-run/docs/naming.md @@ -2,11 +2,11 @@ ## npm packages -The public package family is unscoped, using the `node-addon-landlock-run` package prefix; platform packages append platform information only: +The public package family belongs to the `@deepseek-ai` scope and uses the `node-addon-landlock-run` package prefix; platform packages append platform information only: ```text -node-addon-landlock-run -node-addon-landlock-run- +@deepseek-ai/node-addon-landlock-run +@deepseek-ai/node-addon-landlock-run- ``` Platform suffixes carry no libc component (binaries are static musl) and no variant component — variants stay inside `prebuilds.json` and binary filenames. diff --git a/native/landlock-run/docs/packaging.md b/native/landlock-run/docs/packaging.md index 9a1be47b2a..ec459eb655 100644 --- a/native/landlock-run/docs/packaging.md +++ b/native/landlock-run/docs/packaging.md @@ -5,9 +5,9 @@ The package family uses the same broad shape as native packages such as esbuild: ## Published packages ```text -node-addon-landlock-run -node-addon-landlock-run-linux-x64 -node-addon-landlock-run-linux-arm64 +@deepseek-ai/node-addon-landlock-run +@deepseek-ai/node-addon-landlock-run-linux-x64 +@deepseek-ai/node-addon-landlock-run-linux-arm64 ``` Unsupported platforms are intentionally absent from `optionalDependencies` — see [support-matrix.md](support-matrix.md). diff --git a/native/landlock-run/docs/release.md b/native/landlock-run/docs/release.md index 353a489c2c..d5eec50b6e 100644 --- a/native/landlock-run/docs/release.md +++ b/native/landlock-run/docs/release.md @@ -47,6 +47,8 @@ Use the main repository's `Landlock Run Release` workflow so every binary is bui The workflow publishes only from the final packed tarballs, in `publish-order.txt` order (platform packages before the entry that optionally depends on them). A current-platform rehearsal can still query npm for metadata about an incompatible optional platform package; that package cannot supply the host launcher, which comes from the matching local tarball. Publishing every platform package before the entry ensures a public entry version never points ahead of its platform packages. The workflow supports npm trusted publishing through GitHub OIDC; without it, provide an `NPM_TOKEN` secret in the `npm-publish` environment. Packages publish with `--access public`. +The three scoped package names must be bootstrapped with an `@deepseek-ai` organization token through the `NPM_TOKEN` fallback: npm [requires a package to exist before a trusted publisher can be configured](https://docs.npmjs.com/cli/v11/commands/npm-trust/). After the first release creates all three packages, configure each package to trust `landlock-run-release.yml` in this repository with the `npm-publish` environment, then remove the fallback token when organization policy permits it. + Manual local fallback (current platform's packages only) — always through `pack-release.mjs`, never `pnpm publish` directly (pnpm's pack path strips the launcher's executable bit; see [packaging.md](packaging.md)): ```sh diff --git a/native/landlock-run/docs/support-matrix.md b/native/landlock-run/docs/support-matrix.md index 96d02b3cf6..e60ad201c1 100644 --- a/native/landlock-run/docs/support-matrix.md +++ b/native/landlock-run/docs/support-matrix.md @@ -4,8 +4,8 @@ | Platform package | GitHub runner (builder of record) | Notes | |---|---|---| -| `node-addon-landlock-run-linux-x64` | `ubuntu-24.04` | static musl — glibc and musl distros alike | -| `node-addon-landlock-run-linux-arm64` | `ubuntu-24.04-arm` | static musl — glibc and musl distros alike | +| `@deepseek-ai/node-addon-landlock-run-linux-x64` | `ubuntu-24.04` | static musl — glibc and musl distros alike | +| `@deepseek-ai/node-addon-landlock-run-linux-arm64` | `ubuntu-24.04-arm` | static musl — glibc and musl distros alike | Enforcement additionally requires a kernel with Landlock enabled (5.13+). The negotiated ABI level decides the probe verdict: every access this build knows governed → `full`; an older ABI governing a subset → `partial` (still confined for everything it supports); Landlock absent or disabled → `unusable`, and the launcher refuses to run commands at all. The probe — not the kernel version — is the authority: a kernel built without Landlock, or with the LSM disabled, probes `unusable` regardless of its version. diff --git a/native/landlock-run/package.json b/native/landlock-run/package.json index 6fe3f7eff9..0fb9b3ddfb 100644 --- a/native/landlock-run/package.json +++ b/native/landlock-run/package.json @@ -1,5 +1,5 @@ { - "name": "node-addon-landlock-run-workspace", + "name": "@deepseek-ai/node-addon-landlock-run-workspace", "version": "0.0.1", "private": true, "type": "module", @@ -22,7 +22,7 @@ "release:verify-packed-install": "node ./scripts/verify-packed-install.mjs" }, "devDependencies": { - "node-addon-landlock-run": "workspace:*", + "@deepseek-ai/node-addon-landlock-run": "workspace:*", "@types/node": "^26.0.1", "tsx": "^4.20.6", "typescript": "^6.0.3" diff --git a/native/landlock-run/packages/entry/README.i18n.yaml b/native/landlock-run/packages/entry/README.i18n.yaml index 47d33e0d70..7c67f49670 100644 --- a/native/landlock-run/packages/entry/README.i18n.yaml +++ b/native/landlock-run/packages/entry/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 native/landlock-run/packages/entry/README.md -README.md: e402cdfe71c4eb81b977a21955fe3fff6bf55fd3 -README.zh.md: e4fcd33a256b51c815cdd1c6771be328bc46f138 +README.md: fff722428c5d213d9fcce0ee87a1d48cdc189884 +README.zh.md: f462fbe3cb0cb8d1d83b4d6b1d8e2f61e88ff69c diff --git a/native/landlock-run/packages/entry/README.md b/native/landlock-run/packages/entry/README.md index e402cdfe71..fff722428c 100644 --- a/native/landlock-run/packages/entry/README.md +++ b/native/landlock-run/packages/entry/README.md @@ -1,11 +1,11 @@ -# node-addon-landlock-run +# @deepseek-ai/node-addon-landlock-run English | [中文](README.zh.md) Landlock self-restrict-then-exec launcher for confining subprocesses on Linux: this entry package resolves the per-platform prebuilt binary, runs its functional enforcement probe, and builds its grant argv — consumers never spell launcher flags or parse launcher output themselves. ```js -import { grantArgs, launcherPath, probe } from 'node-addon-landlock-run'; +import { grantArgs, launcherPath, probe } from '@deepseek-ai/node-addon-landlock-run'; const launcher = launcherPath(); if (probe(launcher) !== 'unusable') { @@ -15,4 +15,4 @@ if (probe(launcher) !== 'unusable') { The launcher installs a Landlock ruleset on itself and `exec`s the wrapped command; the ruleset is inherited across `execve`, so the whole process tree runs confined. Everything not granted is denied, and launcher failures exit `125` without running the command — fail-closed, never fail-open. The binary contract is pinned in the repo's `docs/cli-contract.md`; the C source rides this tarball (`src/main.c`) for audit. -Platform packages (`os`/`cpu`-selected optional dependencies, no JavaScript inside): `node-addon-landlock-run-linux-x64`, `node-addon-landlock-run-linux-arm64`. On hosts without one, `launcherPath()` returns a deterministic nonexistent path and `probe()` reports `'unusable'` — there is deliberately no install-time compile fallback. +Platform packages (`os`/`cpu`-selected optional dependencies, no JavaScript inside): `@deepseek-ai/node-addon-landlock-run-linux-x64`, `@deepseek-ai/node-addon-landlock-run-linux-arm64`. On hosts without one, `launcherPath()` returns a deterministic nonexistent path and `probe()` reports `'unusable'` — there is deliberately no install-time compile fallback. diff --git a/native/landlock-run/packages/entry/README.zh.md b/native/landlock-run/packages/entry/README.zh.md index e4fcd33a25..f462fbe3cb 100644 --- a/native/landlock-run/packages/entry/README.zh.md +++ b/native/landlock-run/packages/entry/README.zh.md @@ -1,11 +1,11 @@ -# node-addon-landlock-run +# @deepseek-ai/node-addon-landlock-run [English](README.md) | 中文 用于在 Linux 上限制子进程的 Landlock「先限制自身、再执行」启动器:此入口包定位对应平台的预构建二进制文件,运行功能性强制执行探测,并构建其授权 argv。消费方无需自行拼写启动器标志或解析启动器输出。 ```js -import { grantArgs, launcherPath, probe } from 'node-addon-landlock-run'; +import { grantArgs, launcherPath, probe } from '@deepseek-ai/node-addon-landlock-run'; const launcher = launcherPath(); if (probe(launcher) !== 'unusable') { @@ -15,4 +15,4 @@ if (probe(launcher) !== 'unusable') { 启动器在自身上安装 Landlock 规则集,再 `exec` 被包装的命令;该规则集会跨 `execve` 继承,因此整个进程树都在限制下运行。未授予的一切都被拒绝;启动器失败时以 `125` 退出且不运行命令:采用失败闭合策略,绝不在失败时放行。二进制契约锁定在仓库的 `docs/cli-contract.md` 中;C 源码作为 `src/main.c` 随该 tarball 分发,便于审计。 -平台包(由 `os`/`cpu` 选择的可选依赖,内部不含 JavaScript):`node-addon-landlock-run-linux-x64`、`node-addon-landlock-run-linux-arm64`。在缺少对应包的宿主上,`launcherPath()` 返回一个固定但不存在的路径,`probe()` 报告 `'unusable'`;系统有意不提供安装时编译回退。 +平台包(由 `os`/`cpu` 选择的可选依赖,内部不含 JavaScript):`@deepseek-ai/node-addon-landlock-run-linux-x64`、`@deepseek-ai/node-addon-landlock-run-linux-arm64`。在缺少对应包的宿主上,`launcherPath()` 返回一个固定但不存在的路径,`probe()` 报告 `'unusable'`;系统有意不提供安装时编译回退。 diff --git a/native/landlock-run/packages/entry/package.json b/native/landlock-run/packages/entry/package.json index 56345b2847..1614df5ad2 100644 --- a/native/landlock-run/packages/entry/package.json +++ b/native/landlock-run/packages/entry/package.json @@ -1,5 +1,5 @@ { - "name": "node-addon-landlock-run", + "name": "@deepseek-ai/node-addon-landlock-run", "version": "0.0.1", "type": "module", "description": "Landlock self-restrict-then-exec launcher for sandboxing subprocesses on Linux: per-platform prebuilt static binaries plus the JS seam that resolves, probes, and speaks their CLI contract", @@ -35,7 +35,7 @@ "access": "public" }, "optionalDependencies": { - "node-addon-landlock-run-linux-arm64": "workspace:*", - "node-addon-landlock-run-linux-x64": "workspace:*" + "@deepseek-ai/node-addon-landlock-run-linux-arm64": "workspace:*", + "@deepseek-ai/node-addon-landlock-run-linux-x64": "workspace:*" } } diff --git a/native/landlock-run/packages/entry/src/index.ts b/native/landlock-run/packages/entry/src/index.ts index 7a4349a5ca..ec909f928a 100644 --- a/native/landlock-run/packages/entry/src/index.ts +++ b/native/landlock-run/packages/entry/src/index.ts @@ -53,7 +53,7 @@ export interface LauncherGrants { /** * Path of the launcher binary for this host: resolved from the per-platform - * npm package `node-addon-landlock-run--` (npm's + * npm package `@deepseek-ai/node-addon-landlock-run--` (npm's * `os`/`cpu` fields make installers fetch only the matching one). When the * package is not resolvable — a platform without one, or an install that * skipped the optional dependency — the returned fallback path points inside @@ -69,7 +69,7 @@ export interface LauncherGrants { export function launcherPath( resolvePackageJson: (specifier: string) => string = createRequire(import.meta.url).resolve, ): string { - const platformPackage = `node-addon-landlock-run-${process.platform}-${process.arch}` + const platformPackage = `@deepseek-ai/node-addon-landlock-run-${process.platform}-${process.arch}` try { return join(dirname(resolvePackageJson(`${platformPackage}/package.json`)), 'bin', LAUNCHER_BIN) } catch { diff --git a/native/landlock-run/packages/entry/src/main.c b/native/landlock-run/packages/entry/src/main.c index af3c2eb3f0..e4e1f5c17e 100644 --- a/native/landlock-run/packages/entry/src/main.c +++ b/native/landlock-run/packages/entry/src/main.c @@ -31,7 +31,7 @@ * linked statically), so the whole audit surface is this file plus the * kernel's stable syscall contract. Built natively per architecture by * `scripts/build.ts` into the per-platform npm packages - * (`node-addon-landlock-run-linux-{x64,arm64}`); the argv grammar, + * (`@deepseek-ai/node-addon-landlock-run-linux-{x64,arm64}`); the argv grammar, * exit codes, and report lines are pinned in `docs/cli-contract.md`. */ diff --git a/native/landlock-run/packages/linux-arm64/README.i18n.yaml b/native/landlock-run/packages/linux-arm64/README.i18n.yaml index f7e057193c..fc5c8f9b11 100644 --- a/native/landlock-run/packages/linux-arm64/README.i18n.yaml +++ b/native/landlock-run/packages/linux-arm64/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 native/landlock-run/packages/linux-arm64/README.md -README.md: e5117988cf0bae2227edaa041700c2f75753899c -README.zh.md: e502b0239b5ed862af579b21e36b8c47d7d6107e +README.md: dfcc9e97dc1393a42ff4b89ac009cdfd31e1497b +README.zh.md: 350044e92f1d0247222cc16c82f03588ed0154c9 diff --git a/native/landlock-run/packages/linux-arm64/README.md b/native/landlock-run/packages/linux-arm64/README.md index e5117988cf..dfcc9e97dc 100644 --- a/native/landlock-run/packages/linux-arm64/README.md +++ b/native/landlock-run/packages/linux-arm64/README.md @@ -1,9 +1,9 @@ -# node-addon-landlock-run-linux-arm64 +# @deepseek-ai/node-addon-landlock-run-linux-arm64 English | [中文](README.zh.md) -Prebuilt `bin/landlock-run` Landlock launcher for linux-arm64 — a static musl binary compiled natively (no cross toolchain) from the C source shipped in [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run). npm's `os`/`cpu` fields select this package at install time; the entry package resolves it to a file path — it ships no JavaScript and is never imported. +Prebuilt `bin/landlock-run` Landlock launcher for linux-arm64 — a static musl binary compiled natively (no cross toolchain) from the C source shipped in [`@deepseek-ai/node-addon-landlock-run`](https://www.npmjs.com/package/@deepseek-ai/node-addon-landlock-run). npm's `os`/`cpu` fields select this package at install time; the entry package resolves it to a file path — it ships no JavaScript and is never imported. The binary is git-ignored and rides the npm tarball via the `files` list; the `prepack` gate refuses to pack when it is missing or has the wrong ELF architecture, and the release pipeline byte-pins the packed binary against the CI build it came from. Static musl linking means one binary for glibc and musl distros alike — hence no libc suffix in the name. -Sibling: `node-addon-landlock-run-linux-x64`. +Sibling: `@deepseek-ai/node-addon-landlock-run-linux-x64`. diff --git a/native/landlock-run/packages/linux-arm64/README.zh.md b/native/landlock-run/packages/linux-arm64/README.zh.md index e502b0239b..350044e92f 100644 --- a/native/landlock-run/packages/linux-arm64/README.zh.md +++ b/native/landlock-run/packages/linux-arm64/README.zh.md @@ -1,9 +1,9 @@ -# node-addon-landlock-run-linux-arm64 +# @deepseek-ai/node-addon-landlock-run-linux-arm64 [English](README.md) | 中文 -面向 linux-arm64 的预构建 `bin/landlock-run` Landlock 启动器:一个由 [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run) 包所附的 C 源码原生编译而成的静态 musl 二进制文件(不使用交叉工具链)。npm 的 `os`/`cpu` 字段在安装时选择此包;入口包将其定位到文件路径。该包不包含 JavaScript,也绝不会被导入。 +面向 linux-arm64 的预构建 `bin/landlock-run` Landlock 启动器:一个由 [`@deepseek-ai/node-addon-landlock-run`](https://www.npmjs.com/package/@deepseek-ai/node-addon-landlock-run) 包所附的 C 源码原生编译而成的静态 musl 二进制文件(不使用交叉工具链)。npm 的 `os`/`cpu` 字段在安装时选择此包;入口包将其定位到文件路径。该包不包含 JavaScript,也绝不会被导入。 该二进制文件被 git 忽略,并通过 `files` 列表进入 npm tarball;如果文件缺失或 ELF 架构错误,`prepack` 门禁会拒绝打包,发布流水线则会按字节核验打包的二进制文件与其来源 CI 构建产物一致。静态 musl 链接使同一个二进制文件同时适用于 glibc 和 musl 发行版,因此名称中没有 libc 后缀。 -同级包:`node-addon-landlock-run-linux-x64`。 +同级包:`@deepseek-ai/node-addon-landlock-run-linux-x64`。 diff --git a/native/landlock-run/packages/linux-arm64/package.json b/native/landlock-run/packages/linux-arm64/package.json index af5467cead..14190e4765 100644 --- a/native/landlock-run/packages/linux-arm64/package.json +++ b/native/landlock-run/packages/linux-arm64/package.json @@ -1,7 +1,7 @@ { - "name": "node-addon-landlock-run-linux-arm64", + "name": "@deepseek-ai/node-addon-landlock-run-linux-arm64", "version": "0.0.1", - "description": "Prebuilt landlock-run Landlock launcher binary for linux-arm64 (static musl) — resolved as a file path by node-addon-landlock-run, never imported", + "description": "Prebuilt landlock-run Landlock launcher binary for linux-arm64 (static musl) — resolved as a file path by @deepseek-ai/node-addon-landlock-run, never imported", "repository": { "type": "git", "url": "git+https://github.com/deepseek-harness/deepseek-harness.git", diff --git a/native/landlock-run/packages/linux-x64/README.i18n.yaml b/native/landlock-run/packages/linux-x64/README.i18n.yaml index 7050c110ef..cb0022b138 100644 --- a/native/landlock-run/packages/linux-x64/README.i18n.yaml +++ b/native/landlock-run/packages/linux-x64/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 native/landlock-run/packages/linux-x64/README.md -README.md: 68b5dfc9b6f437a387c3792ee047a1f11630aca0 -README.zh.md: 3b9578a7eb78dfc05977795ca521cf3a881e9f1a +README.md: d08cc0c4abbc74f64c5d1075dea796427211bd8f +README.zh.md: ed6839aa6230b16b82c67a716fc0a4128e5a977c diff --git a/native/landlock-run/packages/linux-x64/README.md b/native/landlock-run/packages/linux-x64/README.md index 68b5dfc9b6..d08cc0c4ab 100644 --- a/native/landlock-run/packages/linux-x64/README.md +++ b/native/landlock-run/packages/linux-x64/README.md @@ -1,9 +1,9 @@ -# node-addon-landlock-run-linux-x64 +# @deepseek-ai/node-addon-landlock-run-linux-x64 English | [中文](README.zh.md) -Prebuilt `bin/landlock-run` Landlock launcher for linux-x64 — a static musl binary compiled natively (no cross toolchain) from the C source shipped in [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run). npm's `os`/`cpu` fields select this package at install time; the entry package resolves it to a file path — it ships no JavaScript and is never imported. +Prebuilt `bin/landlock-run` Landlock launcher for linux-x64 — a static musl binary compiled natively (no cross toolchain) from the C source shipped in [`@deepseek-ai/node-addon-landlock-run`](https://www.npmjs.com/package/@deepseek-ai/node-addon-landlock-run). npm's `os`/`cpu` fields select this package at install time; the entry package resolves it to a file path — it ships no JavaScript and is never imported. The binary is git-ignored and rides the npm tarball via the `files` list; the `prepack` gate refuses to pack when it is missing or has the wrong ELF architecture, and the release pipeline byte-pins the packed binary against the CI build it came from. Static musl linking means one binary for glibc and musl distros alike — hence no libc suffix in the name. -Sibling: `node-addon-landlock-run-linux-arm64`. +Sibling: `@deepseek-ai/node-addon-landlock-run-linux-arm64`. diff --git a/native/landlock-run/packages/linux-x64/README.zh.md b/native/landlock-run/packages/linux-x64/README.zh.md index 3b9578a7eb..ed6839aa62 100644 --- a/native/landlock-run/packages/linux-x64/README.zh.md +++ b/native/landlock-run/packages/linux-x64/README.zh.md @@ -1,9 +1,9 @@ -# node-addon-landlock-run-linux-x64 +# @deepseek-ai/node-addon-landlock-run-linux-x64 [English](README.md) | 中文 -面向 linux-x64 的预构建 `bin/landlock-run` Landlock 启动器:一个由 [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run) 包所附的 C 源码原生编译而成的静态 musl 二进制文件(不使用交叉工具链)。npm 的 `os`/`cpu` 字段在安装时选择此包;入口包将其定位到文件路径。该包不包含 JavaScript,也绝不会被导入。 +面向 linux-x64 的预构建 `bin/landlock-run` Landlock 启动器:一个由 [`@deepseek-ai/node-addon-landlock-run`](https://www.npmjs.com/package/@deepseek-ai/node-addon-landlock-run) 包所附的 C 源码原生编译而成的静态 musl 二进制文件(不使用交叉工具链)。npm 的 `os`/`cpu` 字段在安装时选择此包;入口包将其定位到文件路径。该包不包含 JavaScript,也绝不会被导入。 该二进制文件被 git 忽略,并通过 `files` 列表进入 npm tarball;如果文件缺失或 ELF 架构错误,`prepack` 门禁会拒绝打包,发布流水线则会按字节核验打包的二进制文件与其来源 CI 构建产物一致。静态 musl 链接使同一个二进制文件同时适用于 glibc 和 musl 发行版,因此名称中没有 libc 后缀。 -同级包:`node-addon-landlock-run-linux-arm64`。 +同级包:`@deepseek-ai/node-addon-landlock-run-linux-arm64`。 diff --git a/native/landlock-run/packages/linux-x64/package.json b/native/landlock-run/packages/linux-x64/package.json index 375d05332a..43c092d17b 100644 --- a/native/landlock-run/packages/linux-x64/package.json +++ b/native/landlock-run/packages/linux-x64/package.json @@ -1,7 +1,7 @@ { - "name": "node-addon-landlock-run-linux-x64", + "name": "@deepseek-ai/node-addon-landlock-run-linux-x64", "version": "0.0.1", - "description": "Prebuilt landlock-run Landlock launcher binary for linux-x64 (static musl) — resolved as a file path by node-addon-landlock-run, never imported", + "description": "Prebuilt landlock-run Landlock launcher binary for linux-x64 (static musl) — resolved as a file path by @deepseek-ai/node-addon-landlock-run, never imported", "repository": { "type": "git", "url": "git+https://github.com/deepseek-harness/deepseek-harness.git", diff --git a/native/landlock-run/scripts/verify-packed-install.mjs b/native/landlock-run/scripts/verify-packed-install.mjs index 60f225a9d2..928fff50f7 100644 --- a/native/landlock-run/scripts/verify-packed-install.mjs +++ b/native/landlock-run/scripts/verify-packed-install.mjs @@ -31,7 +31,7 @@ import { entryDirs, packageDirs, platformDirs, readJson, root } from './repo.mjs const args = process.argv.slice(2); const currentPlatformOnly = args.includes('--current-platform-only'); const tarballDir = path.resolve(args.find((arg) => !arg.startsWith('--')) || path.join(root, 'dist', 'npm')); -const entryPackageName = 'node-addon-landlock-run'; +const entryPackageName = '@deepseek-ai/node-addon-landlock-run'; function tarballName(manifest) { if (manifest.name.startsWith('@')) { @@ -180,10 +180,10 @@ import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { grantArgs, launcherPath, probe } from 'node-addon-landlock-run'; +import { grantArgs, launcherPath, probe } from '@deepseek-ai/node-addon-landlock-run'; const requireLandlock = process.env.NALR_REQUIRE_LANDLOCK === '1'; -const platformPackage = 'node-addon-landlock-run-' + process.platform + '-' + process.arch; +const platformPackage = '@deepseek-ai/node-addon-landlock-run-' + process.platform + '-' + process.arch; const resolved = launcherPath(); assert.ok(path.isAbsolute(resolved), 'launcherPath must be absolute'); assert.ok(resolved.includes(path.join(...platformPackage.split('/'))), 'launcherPath must point into the platform package: ' + resolved); diff --git a/native/landlock-run/test/entry.test.js b/native/landlock-run/test/entry.test.js index 2e2cfe8f17..2b535559a2 100644 --- a/native/landlock-run/test/entry.test.js +++ b/native/landlock-run/test/entry.test.js @@ -15,7 +15,7 @@ import { grantArgs, launcherPath, probe, -} from 'node-addon-landlock-run'; +} from '@deepseek-ai/node-addon-landlock-run'; // --- constants are part of the CLI contract --- assert.equal(LAUNCHER_BIN, 'landlock-run'); @@ -31,7 +31,7 @@ assert.deepEqual( assert.deepEqual(grantArgs({ readWrite: ['/a'], readOnly: ['/b'] }), ['--ro', '/b', '--rw', '/a']); // --- launcherPath: resolves the platform package next to its package.json --- -const platformPackage = `node-addon-landlock-run-${process.platform}-${process.arch}`; +const platformPackage = `@deepseek-ai/node-addon-landlock-run-${process.platform}-${process.arch}`; const resolvedViaSeam = launcherPath((specifier) => { assert.equal(specifier, `${platformPackage}/package.json`); return path.join('/fake-install', specifier); diff --git a/native/landlock-run/test/launcher.test.js b/native/landlock-run/test/launcher.test.js index 55385d2156..a78501cd4a 100644 --- a/native/landlock-run/test/launcher.test.js +++ b/native/landlock-run/test/launcher.test.js @@ -22,7 +22,7 @@ import { grantArgs, launcherPath, probe, -} from 'node-addon-landlock-run'; +} from '@deepseek-ai/node-addon-landlock-run'; const FATAL_PREFIX = 'landlock-run: '; const PARTIAL_NOTICE = 'landlock-run: partial enforcement (older Landlock ABI)'; diff --git a/packages/bash/bash-sandbox/package.json b/packages/bash/bash-sandbox/package.json index 6a29c3f6c8..a771acb1d3 100644 --- a/packages/bash/bash-sandbox/package.json +++ b/packages/bash/bash-sandbox/package.json @@ -41,6 +41,6 @@ "@deepseek-ai/dsh-sandbox-local": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "cordis": "^4.0.0-rc.7", - "node-addon-landlock-run": "workspace:*" + "@deepseek-ai/node-addon-landlock-run": "workspace:*" } } diff --git a/packages/bash/bash-sandbox/tests/landlock.e2e.ts b/packages/bash/bash-sandbox/tests/landlock.e2e.ts index 7579292fe1..7255ee43c9 100644 --- a/packages/bash/bash-sandbox/tests/landlock.e2e.ts +++ b/packages/bash/bash-sandbox/tests/landlock.e2e.ts @@ -5,7 +5,7 @@ import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' -import { launcherPath } from 'node-addon-landlock-run' +import { launcherPath } from '@deepseek-ai/node-addon-landlock-run' import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' diff --git a/packages/bash/bash-sandbox/tests/partial-landlock.spec.ts b/packages/bash/bash-sandbox/tests/partial-landlock.spec.ts index 23546e5d92..b578716c43 100644 --- a/packages/bash/bash-sandbox/tests/partial-landlock.spec.ts +++ b/packages/bash/bash-sandbox/tests/partial-landlock.spec.ts @@ -9,7 +9,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' -import { LAUNCHER_FAILURE_EXIT } from 'node-addon-landlock-run' +import { LAUNCHER_FAILURE_EXIT } from '@deepseek-ai/node-addon-landlock-run' import { SANDBOX_UNAVAILABLE, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' diff --git a/packages/examples/agent-spine-demo/package.json b/packages/examples/agent-spine-demo/package.json index b9f817d6e5..e6c6ce351d 100644 --- a/packages/examples/agent-spine-demo/package.json +++ b/packages/examples/agent-spine-demo/package.json @@ -84,7 +84,7 @@ "@deepseek-ai/dsh-tool-tasks": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-workspace-context": "workspace:^", - "node-addon-landlock-run": "workspace:*", + "@deepseek-ai/node-addon-landlock-run": "workspace:*", "cordis": "^4.0.0-rc.7" }, "dependencies": { diff --git a/packages/examples/agent-spine-demo/tests/multi-project-sandbox.e2e.ts b/packages/examples/agent-spine-demo/tests/multi-project-sandbox.e2e.ts index 46f3f56dc3..53722aa7e5 100644 --- a/packages/examples/agent-spine-demo/tests/multi-project-sandbox.e2e.ts +++ b/packages/examples/agent-spine-demo/tests/multi-project-sandbox.e2e.ts @@ -15,7 +15,7 @@ import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' import { SessionId } from '@deepseek-ai/dsh-session' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' import type { ToolResult } from '@deepseek-ai/dsh-tools' -import { launcherPath } from 'node-addon-landlock-run' +import { launcherPath } from '@deepseek-ai/node-addon-landlock-run' import * as agentSpine from '../src/index.ts' const bwrapUsable = spawnSync('bwrap', [ diff --git a/packages/sandbox/sandbox-local/README.i18n.yaml b/packages/sandbox/sandbox-local/README.i18n.yaml index 43fb941975..cbc1e9ad55 100644 --- a/packages/sandbox/sandbox-local/README.i18n.yaml +++ b/packages/sandbox/sandbox-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/sandbox/sandbox-local/README.md -README.md: f6a1cc2b3e454e0670a564151d41182ec515bdcf -README.zh.md: 18b66af350932fc8d5c4f184d0e7fa049f910250 +README.md: 23d3a32451c105c71c0a7399ed051288b70753f3 +README.zh.md: 165a6fc88a9fdd219c3ddb016cdf415504556d8c diff --git a/packages/sandbox/sandbox-local/README.md b/packages/sandbox/sandbox-local/README.md index f6a1cc2b3e..23d3a32451 100644 --- a/packages/sandbox/sandbox-local/README.md +++ b/packages/sandbox/sandbox-local/README.md @@ -12,7 +12,7 @@ Policy is per call; the provider stores only the mechanism and cached runner ver The Seatbelt profile is allow-default with `(deny file-write*)` plus write allow-lists, so exactly the mode's promised file effects are governed: `read-only` grants the `/dev/null` literal alone; `workspace-write` adds the workspace root, `/tmp`, and the per-user darwin temp dir (`os.tmpdir()` — the platform's real temp area for mkstemp-family tools), every root canonicalized because Seatbelt matches resolved paths (`/tmp` IS `/private/tmp`). Apple marks the `sandbox-exec` CLI deprecated but ships it on every macOS; the functional probe is what fails closed if that ever changes. -[`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run) supplies the platform launcher, functional probe, and CLI argument vocabulary. This provider owns only mode-to-grant mapping and runner selection. Keeping path resolution and probe parsing with the versioned binary prevents contract drift. +[`@deepseek-ai/node-addon-landlock-run`](https://www.npmjs.com/package/@deepseek-ai/node-addon-landlock-run) supplies the platform launcher, functional probe, and CLI argument vocabulary. This provider owns only mode-to-grant mapping and runner selection. Keeping path resolution and probe parsing with the versioned binary prevents contract drift. ```yaml - id: sandbox diff --git a/packages/sandbox/sandbox-local/README.zh.md b/packages/sandbox/sandbox-local/README.zh.md index 18b66af350..165a6fc88a 100644 --- a/packages/sandbox/sandbox-local/README.zh.md +++ b/packages/sandbox/sandbox-local/README.zh.md @@ -12,7 +12,7 @@ Seatbelt profile 默认允许,但带 `(deny file-write*)` 和写入 allow-list,因此恰好约束相应模式承诺的文件操作:`read-only` 只授予 `/dev/null` 字面路径;`workspace-write` 另加工作区根目录、`/tmp` 和逐用户 darwin 临时目录(`os.tmpdir()`,即平台供 mkstemp 家族工具使用的真实临时区域)。每个根目录都经过规范化,因为 Seatbelt 匹配解析后的路径(`/tmp` 就是 `/private/tmp`)。Apple 将 `sandbox-exec` CLI(命令行界面)标为 deprecated,但所有 macOS 系统仍会提供它;若情况发生变化,功能探测会使执行被拒绝。 -[`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run)提供平台 launcher、功能探测和 CLI 参数词汇。该提供方只负责模式到授权的映射与 runner 选择。把路径解析和探测解析保留在带版本的 binary 中,可防止契约漂移。 +[`@deepseek-ai/node-addon-landlock-run`](https://www.npmjs.com/package/@deepseek-ai/node-addon-landlock-run)提供平台 launcher、功能探测和 CLI 参数词汇。该提供方只负责模式到授权的映射与 runner 选择。把路径解析和探测解析保留在带版本的 binary 中,可防止契约漂移。 ```yaml - id: sandbox diff --git a/packages/sandbox/sandbox-local/package.json b/packages/sandbox/sandbox-local/package.json index f7004c4d5c..eace2ef08c 100644 --- a/packages/sandbox/sandbox-local/package.json +++ b/packages/sandbox/sandbox-local/package.json @@ -31,7 +31,7 @@ "cordis": "^4.0.0-rc.7" }, "dependencies": { - "node-addon-landlock-run": "workspace:*", + "@deepseek-ai/node-addon-landlock-run": "workspace:*", "schemastery": "^3.18.0" }, "devDependencies": { diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts index 64e92d9bf2..7e9405d14b 100644 --- a/packages/sandbox/sandbox-local/src/index.ts +++ b/packages/sandbox/sandbox-local/src/index.ts @@ -12,7 +12,7 @@ import { LAUNCHER_FAILURE_EXIT, launcherPath as landlockLauncherPath, probe as defaultProbeLandlock, -} from 'node-addon-landlock-run' +} from '@deepseek-ai/node-addon-landlock-run' import { Context } from 'cordis' import z from 'schemastery' import { assertNever } from '@deepseek-ai/dsh-llm' diff --git a/packages/sandbox/sandbox-local/src/profiles.ts b/packages/sandbox/sandbox-local/src/profiles.ts index cee0f00852..5b76390319 100644 --- a/packages/sandbox/sandbox-local/src/profiles.ts +++ b/packages/sandbox/sandbox-local/src/profiles.ts @@ -4,7 +4,7 @@ * @module @deepseek-ai/dsh-sandbox-local/profiles */ -import { grantArgs as landlockGrantArgs } from 'node-addon-landlock-run' +import { grantArgs as landlockGrantArgs } from '@deepseek-ai/node-addon-landlock-run' import { writableRoots } from '@deepseek-ai/dsh-sandbox' import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox' diff --git a/packages/sandbox/sandbox-local/tests/landlock.e2e.ts b/packages/sandbox/sandbox-local/tests/landlock.e2e.ts index 6e2faecc6b..ff4947a4ca 100644 --- a/packages/sandbox/sandbox-local/tests/landlock.e2e.ts +++ b/packages/sandbox/sandbox-local/tests/landlock.e2e.ts @@ -6,7 +6,7 @@ import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox' -import { launcherPath } from 'node-addon-landlock-run' +import { launcherPath } from '@deepseek-ai/node-addon-landlock-run' import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' /** diff --git a/packages/sandbox/sandbox-local/tests/local.spec.ts b/packages/sandbox/sandbox-local/tests/local.spec.ts index 74d4c2a8a1..b1b8b2c4a8 100644 --- a/packages/sandbox/sandbox-local/tests/local.spec.ts +++ b/packages/sandbox/sandbox-local/tests/local.spec.ts @@ -12,7 +12,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import { LAUNCHER_FAILURE_EXIT } from 'node-addon-landlock-run' +import { LAUNCHER_FAILURE_EXIT } from '@deepseek-ai/node-addon-landlock-run' import { SANDBOX_UNAVAILABLE, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import { diff --git a/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts b/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts index 612751e6da..5f4fa5a3bb 100644 --- a/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts +++ b/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts @@ -23,6 +23,7 @@ const packageDir = fileURLToPath(new URL('..', import.meta.url)) const repoRoot = fileURLToPath(new URL('../../../..', import.meta.url)) const nativeDir = join(repoRoot, 'native/landlock-run') const sourceLauncher = join(nativeDir, 'packages', `linux-${process.arch}`, 'bin', 'landlock-run') +const platformPackageName = `@deepseek-ai/node-addon-landlock-run-linux-${process.arch}` /** The harness closure the consumer needs; native tarballs are packed through their mode-preserving release script. */ const WORKSPACE_CLOSURE = [ @@ -108,7 +109,7 @@ describe.skipIf(!packable)('sandbox-local: packed-tarball distribution (publish- import { spawnSync } from 'node:child_process' import { existsSync } from 'node:fs' import { Context } from 'cordis' - import { launcherPath } from 'node-addon-landlock-run' + import { launcherPath } from '@deepseek-ai/node-addon-landlock-run' import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' const ctx = new Context() await ctx.plugin(LocalSandboxProvider, {}) @@ -145,7 +146,7 @@ describe.skipIf(!packable)('sandbox-local: packed-tarball distribution (publish- }) it('installs this checkout\'s launcher for the host: present, executable, byte-identical, and right ELF arch', () => { - const installed = join(consumerDir, 'node_modules', `node-addon-landlock-run-linux-${process.arch}`, 'bin', 'landlock-run') + const installed = join(consumerDir, 'node_modules', ...platformPackageName.split('/'), 'bin', 'landlock-run') expect(existsSync(installed), 'platform package missing from the installed tree').toBe(true) // A tarball or extraction step that strips the mode bit would leave the // probe failing exactly like a non-enforcing kernel — assert it apart. @@ -156,7 +157,7 @@ describe.skipIf(!packable)('sandbox-local: packed-tarball distribution (publish- it('the installed provider resolves the launcher INSIDE the consumer node_modules platform package', () => { expect(verdict.launcher) - .toBe(join(consumerDir, 'node_modules', `node-addon-landlock-run-linux-${process.arch}`, 'bin', 'landlock-run')) + .toBe(join(consumerDir, 'node_modules', ...platformPackageName.split('/'), 'bin', 'landlock-run')) }) it('confines through the installed launcher (enforcing kernel) or fails closed (non-enforcing) — never unconfined', async () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ef10b7c2d7..a93b757791 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -853,12 +853,12 @@ importers: native/landlock-run: devDependencies: + '@deepseek-ai/node-addon-landlock-run': + specifier: workspace:* + version: link:packages/entry '@types/node': specifier: ^26.0.1 version: 26.1.2 - node-addon-landlock-run: - specifier: workspace:* - version: link:packages/entry tsx: specifier: ^4.20.6 version: 4.22.4 @@ -868,10 +868,10 @@ importers: native/landlock-run/packages/entry: optionalDependencies: - node-addon-landlock-run-linux-arm64: + '@deepseek-ai/node-addon-landlock-run-linux-arm64': specifier: workspace:* version: link:../linux-arm64 - node-addon-landlock-run-linux-x64: + '@deepseek-ai/node-addon-landlock-run-linux-x64': specifier: workspace:* version: link:../linux-x64 @@ -1010,12 +1010,12 @@ importers: '@deepseek-ai/dsh-subprocess-local': specifier: workspace:^ version: link:../../subprocess/subprocess-local + '@deepseek-ai/node-addon-landlock-run': + specifier: workspace:* + version: link:../../../native/landlock-run/packages/entry cordis: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis - node-addon-landlock-run: - specifier: workspace:* - version: link:../../../native/landlock-run/packages/entry packages/bash/pwsh-local: dependencies: @@ -2969,12 +2969,12 @@ importers: '@deepseek-ai/dsh-workspace-context': specifier: workspace:^ version: link:../../context/workspace-context + '@deepseek-ai/node-addon-landlock-run': + specifier: workspace:* + version: link:../../../native/landlock-run/packages/entry cordis: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis - node-addon-landlock-run: - specifier: workspace:* - version: link:../../../native/landlock-run/packages/entry packages/examples/cli-demo: devDependencies: @@ -4255,7 +4255,7 @@ importers: packages/sandbox/sandbox-local: dependencies: - node-addon-landlock-run: + '@deepseek-ai/node-addon-landlock-run': specifier: workspace:* version: link:../../../native/landlock-run/packages/entry schemastery: diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 0a446d77f8..dabd0f7805 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -31,10 +31,14 @@ const vendoredPackages = new Set([ '@cordisjs/plugin-logger-console', ]) const publicLandlockPackages = new Set([ - 'node-addon-landlock-run', - 'node-addon-landlock-run-linux-arm64', - 'node-addon-landlock-run-linux-x64', + '@deepseek-ai/node-addon-landlock-run', + '@deepseek-ai/node-addon-landlock-run-linux-arm64', + '@deepseek-ai/node-addon-landlock-run-linux-x64', ]) +/** Deliberate source payloads whose exact bytes are part of the package's audit surface. */ +const publicationSourceAllowlist: Readonly> = { + '@deepseek-ai/node-addon-landlock-run': ['src/main.c'], +} const repositoryUrl = 'git+https://github.com/deepseek-harness/deepseek-harness.git' const localArtifactDirs = new Set(['node_modules']) @@ -200,8 +204,9 @@ function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] { } if (manifest.name?.startsWith('@deepseek-ai/')) { + const allowedSources = publicationSourceAllowlist[manifest.name] ?? [] for (const file of manifest.files ?? []) { - if (isForbiddenPublicationFile(file)) { + if (isForbiddenPublicationFile(file) && !allowedSources.includes(file)) { errors.push(`${label}: package.json files must not publish ${JSON.stringify(file)}`) } } diff --git a/scripts/gen-third-party-notices.ts b/scripts/gen-third-party-notices.ts index e56cce670a..04ea0cf8eb 100644 --- a/scripts/gen-third-party-notices.ts +++ b/scripts/gen-third-party-notices.ts @@ -41,9 +41,9 @@ const DEV_ONLY_AREAS = [ /** First-party public native packages: reachable at runtime but not third-party. */ const FIRST_PARTY = new Set([ - 'node-addon-landlock-run', - 'node-addon-landlock-run-linux-arm64', - 'node-addon-landlock-run-linux-x64', + '@deepseek-ai/node-addon-landlock-run', + '@deepseek-ai/node-addon-landlock-run-linux-arm64', + '@deepseek-ai/node-addon-landlock-run-linux-x64', ]) /** @@ -587,7 +587,7 @@ ${BUILD_TIME_TOOLS.map(tool => `| [\`${tool.name}\`](${tool.repo}) | ${tool.lice ## First-party native packages -\`node-addon-landlock-run\` (and its platform packages) is built and released from this repository under BSD 3-Clause. It is listed here for completeness; it is first-party, not third-party. +\`@deepseek-ai/node-addon-landlock-run\` (and its platform packages) is built and released from this repository under BSD 3-Clause. It is listed here for completeness; it is first-party, not third-party. ` } diff --git a/tsconfig.base.json b/tsconfig.base.json index 634464cb9b..2965a1f794 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -38,7 +38,7 @@ "@cordisjs/plugin-timer": ["./vendor/timer/src"], "@cordisjs/plugin-hmr": ["./vendor/hmr/src"], "@cordisjs/plugin-logger-console": ["./vendor/logger-console/src"], - "node-addon-landlock-run": ["./native/landlock-run/packages/entry/src/index.ts"], + "@deepseek-ai/node-addon-landlock-run": ["./native/landlock-run/packages/entry/src/index.ts"], "@deepseek-ai/dsh-invariants": ["./packages/support/invariants/src/index.ts"], "@deepseek-ai/dsh-typert-registry": ["./packages/typert/registry/src/index.ts"], "@deepseek-ai/dsh-typert-loader": ["./packages/typert/loader/src/index.ts"], From 928c99876e8e3a66a730f759236525f7944a4a0f Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 5 Aug 2026 21:50:44 +0800 Subject: [PATCH 024/109] feat: add optional dsh badge skill provider --- ...26-08-06-bundled-dsh-badge-skill.i18n.yaml | 6 + .../2026-08-06-bundled-dsh-badge-skill.md | 25 +++ .../2026-08-06-bundled-dsh-badge-skill.zh.md | 25 +++ apps/cli/composition.md | 3 + apps/cli/config/base.cordis.yml | 4 + apps/cli/package.json | 1 + apps/cli/tests/dsh-badge.snapshot.ts | 173 ++++++++++++++++++ apps/cli/tests/fixtures/dsh-badge/cordis.yml | 9 + .../fixtures/dsh-badge/default.cordis.yml | 6 + apps/cli/tests/fixtures/dsh-badge/snapshot.ts | 53 ++++++ docs/capability-seams.md | 4 +- docs/config-catalog.md | 1 + docs/module-graph.md | 4 + knip.json | 3 +- packages/skill/README.i18n.yaml | 4 +- packages/skill/README.md | 1 + packages/skill/README.zh.md | 1 + packages/skill/skill-badge/README.i18n.yaml | 6 + packages/skill/skill-badge/README.md | 22 +++ packages/skill/skill-badge/README.zh.md | 22 +++ .../skill/skill-badge/assets/dsh-badge.md | 31 ++++ .../skill/skill-badge/assets/dsh-badge.png | Bin 0 -> 12339 bytes packages/skill/skill-badge/package.json | 37 ++++ packages/skill/skill-badge/src/index.ts | 60 ++++++ packages/skill/skill-badge/src/invariant.ts | 30 +++ .../skill-badge/tests/skill-badge.spec.ts | 40 ++++ packages/skill/skill-badge/tsconfig.json | 14 ++ pnpm-lock.yaml | 15 ++ scripts/check-workspace-constraints.ts | 1 + scripts/gen-doc-graphs.ts | 2 +- .../verify-package-readme-model-experience.ts | 1 + tsconfig.host.json | 1 + vitest.snapshot.config.ts | 1 + 33 files changed, 601 insertions(+), 5 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.md create mode 100644 .agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.zh.md create mode 100644 apps/cli/tests/dsh-badge.snapshot.ts create mode 100644 apps/cli/tests/fixtures/dsh-badge/cordis.yml create mode 100644 apps/cli/tests/fixtures/dsh-badge/default.cordis.yml create mode 100644 apps/cli/tests/fixtures/dsh-badge/snapshot.ts create mode 100644 packages/skill/skill-badge/README.i18n.yaml create mode 100644 packages/skill/skill-badge/README.md create mode 100644 packages/skill/skill-badge/README.zh.md create mode 100644 packages/skill/skill-badge/assets/dsh-badge.md create mode 100644 packages/skill/skill-badge/assets/dsh-badge.png create mode 100644 packages/skill/skill-badge/package.json create mode 100644 packages/skill/skill-badge/src/index.ts create mode 100644 packages/skill/skill-badge/src/invariant.ts create mode 100644 packages/skill/skill-badge/tests/skill-badge.spec.ts create mode 100644 packages/skill/skill-badge/tsconfig.json diff --git a/.agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.i18n.yaml b/.agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.i18n.yaml new file mode 100644 index 0000000000..bdc103ef46 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.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/feature/2026-08-06-bundled-dsh-badge-skill.md +2026-08-06-bundled-dsh-badge-skill.md: afe0b21d64a414a9e78ef55459a42c0d3817e3fd +2026-08-06-bundled-dsh-badge-skill.zh.md: de1ec989570b07987b12f0a291c84643aa5531fd diff --git a/.agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.md b/.agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.md new file mode 100644 index 0000000000..afe0b21d64 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.md @@ -0,0 +1,25 @@ +# Agent Note: Bundled dsh badge skill + +Status: implemented + +English | [中文](2026-08-06-bundled-dsh-badge-skill.zh.md) + +## Problem + +DeepSeek Harness has an official attribution badge skill, but keeping it only in a developer's personal skill directory makes it unavailable to other DSH installations and gives the shipped application no explicit opt-in point. + +## Decision + +`@deepseek-ai/dsh-skill-badge` is a native Cordis plugin that registers one immutable bundled provider on `ctx.skills`. The provider owns the `dsh-badge` summary, instruction body, and PNG resource base; `dsh-tool-skill` remains the sole owner of model-facing catalog and loader rendering. + +The shipped CLI composition declares `skill-badge` as disabled. Enabling that existing row is the explicit opt-in; disabled installations advertise no badge skill and gain no model-visible content. + +The provider uses the bundled rank after project, custom, and user filesystem sources, so a user-owned `dsh-badge` definition can override it through the ordinary registry precedence contract. Provider disposal removes the contribution through the registry-owned effect. + +## Alternatives considered + +A Codex marketplace plugin was rejected because it would install into a different runtime and would not participate in DSH's `ctx.skills` seam. Mounting `dsh-skill-local` over the packaged files was rejected because filesystem discovery, parsing, and watching add lifecycle machinery that an immutable single-skill provider does not need. + +## Consequences + +The badge instructions and source PNG are versioned with DSH and resolve through a packaged directory resource base. The provider has no configuration surface. Package tests pin provider lifecycle and the official PNG bytes, while a keyless assembled-application snapshot pins the enabled catalog and loaded skill body. diff --git a/.agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.zh.md b/.agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.zh.md new file mode 100644 index 0000000000..de1ec98957 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.zh.md @@ -0,0 +1,25 @@ +# Agent Note: 内置 dsh 徽章 skill + +Status: implemented + +[English](2026-08-06-bundled-dsh-badge-skill.md) | 中文 + +## 问题 + +DeepSeek Harness 已有官方署名徽章 skill(技能),但如果它只保存在某位开发者的个人 skill 目录中,其他 DSH 安装实例便无法使用,交付的应用也没有显式的选择加入点。 + +## 决策 + +`@deepseek-ai/dsh-skill-badge` 是一个原生 Cordis 插件,会在 `ctx.skills` 上注册一个不可变的内置提供方。该提供方负责 `dsh-badge` 的摘要、指令正文和 PNG 资源基底;`dsh-tool-skill` 仍是面向模型的目录与 loader 渲染的唯一归属方。 + +交付的 CLI(命令行界面)组合将 `skill-badge` 声明为禁用。启用这个现有配置行就是显式选择加入;禁用它的安装实例不会公开任何徽章 skill,也不会获得任何模型可见内容。 + +该提供方使用排在项目、自定义及用户文件系统来源之后的内置 rank,因此用户自有的 `dsh-badge` 定义可通过注册表的常规优先级契约覆盖它。提供方释放时,注册表拥有的 effect 会移除该贡献。 + +## 曾考虑的替代方案 + +未采用 Codex marketplace 插件,因为它会安装到不同的运行时,无法参与 DSH 的 `ctx.skills` seam。未采用使用 `dsh-skill-local` 挂载随包文件的方案,因为文件系统发现、解析和监视会引入不必要的生命周期机制,而不可变的单一 skill 提供方并不需要这些机制。 + +## 后果 + +徽章指令和源 PNG 随 DSH 一同纳入版本管理,并通过以随包目录为基础的资源基底解析。该提供方没有配置面。包测试固定提供方生命周期和官方 PNG 的字节内容;无密钥的组装应用快照则固定启用后的目录和已加载的 skill 正文。 diff --git a/apps/cli/composition.md b/apps/cli/composition.md index 28f58bcf4d..8edf39a07a 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -72,6 +72,8 @@ flowchart LR cfg --> plugin_dsh_base_skill plugin_dsh_base_skill_local["skill-local
@deepseek-ai/dsh-skill-local"] cfg --> plugin_dsh_base_skill_local + plugin_dsh_base_skill_badge["skill-badge
@deepseek-ai/dsh-skill-badge"] + cfg --> plugin_dsh_base_skill_badge plugin_dsh_base_tool_skill["tool-skill
@deepseek-ai/dsh-tool-skill"] cfg --> plugin_dsh_base_tool_skill plugin_dsh_base_commands["commands
@deepseek-ai/dsh-commands"] @@ -182,6 +184,7 @@ flowchart LR | `workspace-context` | `@deepseek-ai/dsh-workspace-context` | | `skill` | `@deepseek-ai/dsh-skill` | | `skill-local` | `@deepseek-ai/dsh-skill-local` | +| `skill-badge` | `@deepseek-ai/dsh-skill-badge` | | `tool-skill` | `@deepseek-ai/dsh-tool-skill` | | `commands` | `@deepseek-ai/dsh-commands` | | `goal` | `@deepseek-ai/dsh-goal` | diff --git a/apps/cli/config/base.cordis.yml b/apps/cli/config/base.cordis.yml index dddf2fc1b5..f5f39a6ae5 100644 --- a/apps/cli/config/base.cordis.yml +++ b/apps/cli/config/base.cordis.yml @@ -204,6 +204,10 @@ - id: skill-local name: '@deepseek-ai/dsh-skill-local' +- id: skill-badge + name: '@deepseek-ai/dsh-skill-badge' + disabled: true + - id: tool-skill name: '@deepseek-ai/dsh-tool-skill' diff --git a/apps/cli/package.json b/apps/cli/package.json index 4ba1da7e86..dfe35e6159 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -92,6 +92,7 @@ "@deepseek-ai/dsh-session-title-first-message-llm": "workspace:^", "@deepseek-ai/dsh-settings-local": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", + "@deepseek-ai/dsh-skill-badge": "workspace:^", "@deepseek-ai/dsh-skill-local": "workspace:^", "@deepseek-ai/dsh-spill-local": "workspace:^", "@deepseek-ai/dsh-spill-policy": "workspace:^", diff --git a/apps/cli/tests/dsh-badge.snapshot.ts b/apps/cli/tests/dsh-badge.snapshot.ts new file mode 100644 index 0000000000..d78f4c743d --- /dev/null +++ b/apps/cli/tests/dsh-badge.snapshot.ts @@ -0,0 +1,173 @@ +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' +const binScript = fileURLToPath(new URL('./fixtures/dsh-badge/snapshot.ts', import.meta.url)) +const configPath = fileURLToPath(new URL('./fixtures/dsh-badge/cordis.yml', import.meta.url)) +const defaultConfigPath = fileURLToPath(new URL('./fixtures/dsh-badge/default.cordis.yml', import.meta.url)) +const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) +const badgeAssetsPath = fileURLToPath(new URL('../../../packages/skill/skill-badge/assets/', import.meta.url)) + +describe('dsh badge assembled snapshot', () => { + it('advertises and loads the opt-in bundled skill through the shipped app', async () => { + const disabled = await runLoaderSmoke({ + label: 'disabled dsh badge skill snapshot', + tempDirPrefix: 'headless-snapshot-dsh-badge-disabled-', + binScript, + libBinScript: binScript, + configPath: defaultConfigPath, + tsconfigPath, + }) + const enabled = await runLoaderSmoke({ + label: 'dsh badge skill snapshot', + tempDirPrefix: 'headless-snapshot-dsh-badge-', + binScript, + libBinScript: binScript, + configPath, + tsconfigPath, + }) + const disabledSnapshot = JSON.parse(disabled.stdout) as unknown + const enabledSnapshot = JSON.parse( + enabled.stdout.replaceAll(badgeAssetsPath, '{{badgeAssetsPath}}'), + ) as unknown + + expect(disabled.stderr).toBe('') + expect(enabled.stderr).toBe('') + expect(disabledSnapshot).toMatchInlineSnapshot(` + { + "result": { + "content": [ + { + "text": "Error: skill "dsh-badge" is unknown or no longer available", + "type": "text", + }, + ], + "error": { + "message": "skill "dsh-badge" is unknown or no longer available", + }, + "isError": true, + }, + } + `) + expect(enabledSnapshot).toMatchInlineSnapshot(` + { + "catalog": [ + { + "text": " + A skill is a reusable set of task-specific instructions. The following skills are available in this session: + + + - \`dsh-badge\`: Add the official “powered by dsh” badge to documents, pull requests, merge requests, and other content produced with DeepSeek Harness. Use whenever creating a pull request or merge request. Also use when the user asks for a dsh badge, powered-by-dsh attribution, or a reusable dsh badge asset or snippet. + + + If the user names a skill, or the task clearly matches a skill's description, call the \`skill\` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded. + ", + "type": "text", + }, + ], + "result": { + "content": [ + { + "text": " + + Base directory for this skill: {{badgeAssetsPath}} + Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed. + + + + # dsh Badge + + Add the official “powered by dsh” badge without recreating or restyling it. + + ## Assets + + - Local PNG: [\`dsh-badge.png\`](dsh-badge.png), 726×120 source image; render at 121×20 + - Shields.io image URL: \`https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white\` + - Project URL: \`https://github.com/deepseek-harness/deepseek-harness\` + + ## Markdown + + Use this linked badge in Markdown: + + \`\`\`markdown + [![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) + \`\`\` + + If attribution should not be linked, use: + + \`\`\`markdown + ![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white) + \`\`\` + + ## Usage rules + + - For GitHub or GitLab Markdown, use the Shields.io URL and link it to the project URL unless the user asks for an unlinked image. + - For Feishu and other systems that import remote images unreliably, upload \`dsh-badge.png\` from this skill directory instead of generating another badge. + - Preserve the badge's 121×20 dimensions and aspect ratio. + - Place the badge at the end of the attributed document or section unless the user specifies another position. + - Do not substitute another color, logo, label, or project URL. + + + ", + "type": "text", + }, + ], + "isError": false, + "value": { + "content": "# dsh Badge + + Add the official “powered by dsh” badge without recreating or restyling it. + + ## Assets + + - Local PNG: [\`dsh-badge.png\`](dsh-badge.png), 726×120 source image; render at 121×20 + - Shields.io image URL: \`https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white\` + - Project URL: \`https://github.com/deepseek-harness/deepseek-harness\` + + ## Markdown + + Use this linked badge in Markdown: + + \`\`\`markdown + [![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) + \`\`\` + + If attribution should not be linked, use: + + \`\`\`markdown + ![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white) + \`\`\` + + ## Usage rules + + - For GitHub or GitLab Markdown, use the Shields.io URL and link it to the project URL unless the user asks for an unlinked image. + - For Feishu and other systems that import remote images unreliably, upload \`dsh-badge.png\` from this skill directory instead of generating another badge. + - Preserve the badge's 121×20 dimensions and aspect ratio. + - Place the badge at the end of the attributed document or section unless the user specifies another position. + - Do not substitute another color, logo, label, or project URL. + ", + "name": "dsh-badge", + "provider": "dsh-badge", + "resourceBase": { + "kind": "directory", + "path": "{{badgeAssetsPath}}", + }, + }, + }, + "summary": { + "description": "Add the official “powered by dsh” badge to documents, pull requests, merge requests, and other content produced with DeepSeek Harness. Use whenever creating a pull request or merge request. Also use when the user asks for a dsh badge, powered-by-dsh attribution, or a reusable dsh badge asset or snippet.", + "invocation": { + "modelInvocable": true, + "userInvocable": true, + }, + "name": "dsh-badge", + "provider": "dsh-badge", + "resourceBase": { + "kind": "directory", + "path": "{{badgeAssetsPath}}", + }, + "source": "bundled", + }, + } + `) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) +}) diff --git a/apps/cli/tests/fixtures/dsh-badge/cordis.yml b/apps/cli/tests/fixtures/dsh-badge/cordis.yml new file mode 100644 index 0000000000..b3bfdb1b04 --- /dev/null +++ b/apps/cli/tests/fixtures/dsh-badge/cordis.yml @@ -0,0 +1,9 @@ +- id: skill-badge + disabled: false + +- id: skill-local + config: + watch: false + +- id: telemetry-otel + disabled: true diff --git a/apps/cli/tests/fixtures/dsh-badge/default.cordis.yml b/apps/cli/tests/fixtures/dsh-badge/default.cordis.yml new file mode 100644 index 0000000000..ac3e48441a --- /dev/null +++ b/apps/cli/tests/fixtures/dsh-badge/default.cordis.yml @@ -0,0 +1,6 @@ +- id: skill-local + config: + watch: false + +- id: telemetry-otel + disabled: true diff --git a/apps/cli/tests/fixtures/dsh-badge/snapshot.ts b/apps/cli/tests/fixtures/dsh-badge/snapshot.ts new file mode 100644 index 0000000000..99379b4fe4 --- /dev/null +++ b/apps/cli/tests/fixtures/dsh-badge/snapshot.ts @@ -0,0 +1,53 @@ +import { fileURLToPath } from 'node:url' +import { Context } from 'cordis' +import { agentEvents, Inbox, type Agent } from '@deepseek-ai/dsh-agent' +import { CallId } from '@deepseek-ai/dsh-llm' +import { boot, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot' +import { SessionId } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-skill' +import type {} from '@deepseek-ai/dsh-tools' + +const overlayPath = process.argv[2] +if (overlayPath === undefined) throw new Error('dsh-badge snapshot requires an overlay path') +const baseConfigPath = fileURLToPath(new URL('../../../config/base.cordis.yml', import.meta.url)) +const ctx = await boot('dsh-badge-snapshot', baseConfigPath, loadOverlayPatches('dsh-badge-snapshot', overlayPath)) + +try { + const agentId = SessionId('dsh-badge-snapshot') + const session = ctx.sessions.create(agentId, { meta: { cwd: process.cwd() } }) + const agent: Agent = { + ctx: new Context(), + id: agentId, + options: {}, + session, + inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + status: 'idle', + send: () => {}, + followup: () => {}, + steer: () => {}, + inject: () => { throw new Error('dsh-badge snapshot must receive the catalog at the step boundary') }, + cancel: () => {}, + runMaintenance: task => task(new AbortController().signal), + whenIdle: () => Promise.resolve(), + } + const decision = await agentEvents(ctx, agent).waterfall( + 'agent/pre-step', + [], + { turn: 1, step: 1, signal: new AbortController().signal }, + () => Promise.resolve({ kind: 'enter' as const, messages: [] }), + ) + const catalog = decision.kind === 'enter' + ? decision.messages.find(message => message.role === 'user' + && message.source.kind === 'skill-catalog')?.content + : undefined + const summary = (await ctx.skills.list()).find(skill => skill.name === 'dsh-badge') + const result = await ctx.tools.execute({ + callId: CallId('dsh-badge-snapshot'), + name: 'skill', + arguments: { name: 'dsh-badge' }, + signal: new AbortController().signal, + }) + process.stdout.write(`${JSON.stringify({ catalog, summary, result })}\n`) +} finally { + await ctx.fiber.dispose() +} diff --git a/docs/capability-seams.md b/docs/capability-seams.md index cb24cee7d5..a5f20b5349 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -89,6 +89,7 @@ flowchart LR svc_sessionProjectionCache["ctx.sessionProjectionCache
Persisted projection cache"] pkg_skill["skill"] svc_skills["ctx.skills
Skill provider registry"] + pkg_skill_badge["skill-badge"] pkg_skill_local["skill-local"] svc_agents["ctx.agents
Agent service"] pkg_acp["acp"] @@ -219,6 +220,7 @@ flowchart LR pkg_settings --> svc_settings pkg_settings_local --> svc_settings pkg_skill --> svc_skills + pkg_skill_badge --> svc_skills pkg_skill_local --> svc_skills pkg_spill --> svc_spillStore pkg_spill_local --> svc_spillStore @@ -373,7 +375,7 @@ flowchart LR | `ctx.commands` | `core` | [`commands`](../packages/ui/commands) | - | - | - | Plugins register direct human commands without sending invocations to the model. | | `ctx.sessionProjections` | `core` | [`session-projection`](../packages/session-projection/session-projection) | - | [`tool-todo`](../packages/todo/tool-todo), [`session-title`](../packages/session-title/session-title), [`host-apiproxy`](../packages/host/apiproxy) | - | Domains register state-driven fold units; the eager drive keeps per-session watermark states and api-proxy serves baselines and pushes changed values. | | `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session-projection/session-projection-cache) | - | [`host-apiproxy`](../packages/host/apiproxy) | - | Durably checkpoints projection unit states per session (throttled + turn/end/detach mandatory points) and serves the cold-read ladder: cache row + persistence tail replay, so listings never load full logs. | -| `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-local`](../packages/skill/skill-local) | [`tool-skill`](../packages/skill/tool-skill) | - | Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies. | +| `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-badge`](../packages/skill/skill-badge), [`skill-local`](../packages/skill/skill-local) | [`tool-skill`](../packages/skill/tool-skill) | - | Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies. | | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. | | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. | | `ctx.goals` | `core` | [`goal`](../packages/goal/goal) | - | - | - | Folds revisioned objective state from the session log and keeps live continuation activation process-local. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index ffa3d5bdd4..449addad8a 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2362,6 +2362,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-session` ([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts)) - `@deepseek-ai/dsh-session-checkpoint-policy` — requires `llm` · `sessionPersistence` · `sessions` · `tools` ([`packages/session-persistence/session-checkpoint-policy/src/index.ts`](../packages/session-persistence/session-checkpoint-policy/src/index.ts)) - `@deepseek-ai/dsh-session-projection` ([`packages/session-projection/session-projection/src/index.ts`](../packages/session-projection/session-projection/src/index.ts)) +- `@deepseek-ai/dsh-skill-badge` — requires `skills` ([`packages/skill/skill-badge/src/index.ts`](../packages/skill/skill-badge/src/index.ts)) - `@deepseek-ai/dsh-storage` ([`packages/storage/storage/src/index.ts`](../packages/storage/storage/src/index.ts)) - `@deepseek-ai/dsh-subagent` ([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts)) - `@deepseek-ai/dsh-subprocess-local` ([`packages/subprocess/subprocess-local/src/index.ts`](../packages/subprocess/subprocess-local/src/index.ts)) diff --git a/docs/module-graph.md b/docs/module-graph.md index 0e2e7e0c37..7f3f68603f 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -56,6 +56,7 @@ flowchart TD end subgraph group_skill["packages/skill"] pkg_skill["skill"] + pkg_skill_badge["skill-badge"] pkg_skill_local["skill-local"] pkg_tool_skill["tool-skill"] end @@ -303,6 +304,8 @@ flowchart TD pkg_llm --> pkg_brand pkg_llm --> pkg_invariants pkg_llm --> pkg_timeout + pkg_skill_badge --> pkg_invariants + pkg_skill_badge --> pkg_skill pkg_client_connection --> pkg_host_webserver pkg_client_connection --> pkg_invariants pkg_client_hmr --> pkg_client_modules @@ -1106,6 +1109,7 @@ flowchart TD | [`typert-generator`](../packages/typert/generator) | `typert` | [`invariants`](../packages/support/invariants) | | [`typert-registry`](../packages/typert/registry) | `typert` | [`invariants`](../packages/support/invariants) | | [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout) | +| [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`invariants`](../packages/support/invariants), [`skill`](../packages/skill/skill) | | [`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-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) | diff --git a/knip.json b/knip.json index dfb8058d7c..8d10010644 100644 --- a/knip.json +++ b/knip.json @@ -622,7 +622,8 @@ "apps/cli": { "entry": [ "tests/**/*.spec.ts", - "tests/**/*.e2e.ts" + "tests/**/*.e2e.ts", + "tests/**/*.snapshot.ts" ], "project": [ "src/**/*.ts", diff --git a/packages/skill/README.i18n.yaml b/packages/skill/README.i18n.yaml index 2d424c61dd..74875f2aa3 100644 --- a/packages/skill/README.i18n.yaml +++ b/packages/skill/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/skill/README.md -README.md: d10049ac3e741350fddb42f430b70f063da1d12d -README.zh.md: 67f2da6f75edecd180ff861122c6392684ed9bdb +README.md: 533904859ad998de4f371a073fde98b68660097b +README.zh.md: 1fad581cc61a05251f671577dcb7edab37281283 diff --git a/packages/skill/README.md b/packages/skill/README.md index d10049ac3e..533904859a 100644 --- a/packages/skill/README.md +++ b/packages/skill/README.md @@ -7,6 +7,7 @@ This family discovers reusable agent instructions and exposes them to the model | Package | Role | ctx key | |---|---|---| | [`skill/`](skill/README.md) | Defines skill provider registration and lookup | `ctx.skills` | +| [`skill-badge/`](skill-badge/README.md) | Contributes the optional bundled dsh badge skill | registers on `ctx.skills` | | [`skill-local/`](skill-local/README.md) | Discovers skills from local filesystems | registers on `ctx.skills` | | [`tool-skill/`](tool-skill/README.md) | Publishes the skill catalog and model-facing loader | registers on `ctx.tools` | diff --git a/packages/skill/README.zh.md b/packages/skill/README.zh.md index 67f2da6f75..1fad581cc6 100644 --- a/packages/skill/README.zh.md +++ b/packages/skill/README.zh.md @@ -7,6 +7,7 @@ | 包 | 职责 | ctx 键 | |---|---|---| | [`skill/`](skill/README.md) | 定义 skill 提供方注册和查找 | `ctx.skills` | +| [`skill-badge/`](skill-badge/README.md) | 贡献可选的内置 dsh 徽章 skill | 注册到 `ctx.skills` | | [`skill-local/`](skill-local/README.md) | 从本地文件系统发现 skill | 注册到 `ctx.skills` | | [`tool-skill/`](tool-skill/README.md) | 发布 skill 目录和面向模型的 loader | 注册到 `ctx.tools` | diff --git a/packages/skill/skill-badge/README.i18n.yaml b/packages/skill/skill-badge/README.i18n.yaml new file mode 100644 index 0000000000..4dda53481c --- /dev/null +++ b/packages/skill/skill-badge/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/skill/skill-badge/README.md +README.md: 49b38023a7c110bb52bb351668905c702e251216 +README.zh.md: bf7eb0d7d4c0552c07f9cdf5665f8a20df829483 diff --git a/packages/skill/skill-badge/README.md b/packages/skill/skill-badge/README.md new file mode 100644 index 0000000000..49b38023a7 --- /dev/null +++ b/packages/skill/skill-badge/README.md @@ -0,0 +1,22 @@ +# @deepseek-ai/dsh-skill-badge + +English | [中文](README.zh.md) + +Optional bundled skill provider that contributes `dsh-badge` to `ctx.skills`. The skill supplies the official “powered by dsh” Markdown snippets and the packaged PNG for systems that cannot import a remote image reliably. + +Mount the plugin to enable the provider. It has no configuration. The shipped CLI composition includes the plugin as `disabled: true`; users must explicitly enable its `skill-badge` row before the skill enters a catalog. + +The provider exposes its packaged `assets/` directory as the skill resource base. `dsh-badge.png` is the 726×120 source asset, and consumers render it at 121×20. + +## Model Experience + +Indirectly, through `@deepseek-ai/dsh-tool-skill`, which renders the catalog entry and selected skill body. + +#### KV Cache effect + +Disabled by default, the plugin changes no request. When enabled, its catalog entry and any loaded body change the provider KV prefix at their insertion points. + +## Known Limitations and Deferred Work + +- The provider contributes one fixed skill and has no runtime customization. +- Remote Markdown uses Shields.io; use the packaged PNG when the target cannot fetch remote images reliably. diff --git a/packages/skill/skill-badge/README.zh.md b/packages/skill/skill-badge/README.zh.md new file mode 100644 index 0000000000..bf7eb0d7d4 --- /dev/null +++ b/packages/skill/skill-badge/README.zh.md @@ -0,0 +1,22 @@ +# @deepseek-ai/dsh-skill-badge + +[English](README.md) | 中文 + +可选的内置 skill(技能)提供方,向 `ctx.skills` 贡献 `dsh-badge`。该 skill 提供官方「powered by dsh」Markdown 片段和随包分发的 PNG,供无法可靠导入远程图片的系统使用。 + +挂载该插件即可启用提供方。它没有配置。交付的 CLI(命令行界面)组合以 `disabled: true` 包含该插件;用户必须显式启用其 `skill-badge` 配置行,该 skill 才会进入目录。 + +该提供方将随包分发的 `assets/` 目录作为 skill 资源基底公开。`dsh-badge.png` 是尺寸为 726×120 的源图资源,消费方以 121×20 的尺寸渲染。 + +## 模型体验 + +通过 `@deepseek-ai/dsh-tool-skill` 间接影响模型;该包会渲染目录条目和所选 skill 的正文。 + +#### KV Cache 影响 + +该插件默认禁用,不会改变任何请求。启用后,其目录条目和任何已加载正文都会在各自插入点改变提供方的 KV 前缀。 + +## 已知限制与暂缓事项 + +- 该提供方只贡献一个固定 skill,不提供运行时自定义。 +- 远程 Markdown 使用 Shields.io;当目标环境无法可靠获取远程图片时,请使用随包分发的 PNG。 diff --git a/packages/skill/skill-badge/assets/dsh-badge.md b/packages/skill/skill-badge/assets/dsh-badge.md new file mode 100644 index 0000000000..9905de1ed9 --- /dev/null +++ b/packages/skill/skill-badge/assets/dsh-badge.md @@ -0,0 +1,31 @@ +# dsh Badge + +Add the official “powered by dsh” badge without recreating or restyling it. + +## Assets + +- Local PNG: [`dsh-badge.png`](dsh-badge.png), 726×120 source image; render at 121×20 +- Shields.io image URL: `https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white` +- Project URL: `https://github.com/deepseek-harness/deepseek-harness` + +## Markdown + +Use this linked badge in Markdown: + +```markdown +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) +``` + +If attribution should not be linked, use: + +```markdown +![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white) +``` + +## Usage rules + +- For GitHub or GitLab Markdown, use the Shields.io URL and link it to the project URL unless the user asks for an unlinked image. +- For Feishu and other systems that import remote images unreliably, upload `dsh-badge.png` from this skill directory instead of generating another badge. +- Preserve the badge's 121×20 dimensions and aspect ratio. +- Place the badge at the end of the attributed document or section unless the user specifies another position. +- Do not substitute another color, logo, label, or project URL. diff --git a/packages/skill/skill-badge/assets/dsh-badge.png b/packages/skill/skill-badge/assets/dsh-badge.png new file mode 100644 index 0000000000000000000000000000000000000000..bf91ecca9790971072e946b56c01bbf99e26abd4 GIT binary patch literal 12339 zcmdUVV|N`5+jg8C+qN1vb{Z#btj4zO2D`ECrg788wrw`HZ71*ay4U+7o-bLmX0Hs+ zIdvdh>5DW9A|WCK1O$q#jD!jV1k^lm>L&!>qsd;9dWW#%6&n*qC z=fQ(QFvZWsL_wb!0+qz+rzMhNKu}Oe;IeN)l+Kb?ny`>KNPR&cVI_7bL3r5Zj$#?) z?6sogH<;w#+t)=}7GA%*m|IxXkJ)<$D3azla_E@vjq<%WWZmx^EY&fwBloZ={Akp- zWoJiL<~GLvZiN3Gf%k*he+N|%DdfQK$URyR^63A5G5YY02K4U)8*V3N$iEZ#NQMaA z|4u}~R>J&uq8~~t=-&xs6UhHNmyw}mXJ?mB`B19Y!u>BK$XMvkO1PAo<+?bGYV;bv zwKluKmp0gtsdAD;jIV>_Ta2!_F+-S_qB_IUn}PL;9b zreZV^kN@qQ=B+35nE^0EtKnh z5~N{fKK(`V5@cd*Z0x#bJrF~pTKt9UD(@nQH{cw2AYBS_vsqPd3p*?cU+>|zjjoYHpz`;|smBwX92+QX~e5z~a9l98wc96JMK z0V~aJK(1jqX7D%&8+JmtjVOw|n)-gcNMO-xc0HPvUA-FPS^uElnwA!4+>c)<`sVW$ zuFYP+CGdmkiW1FJPI^TDc;)HmpGpJ92-84#fJ?SYgk$cgs;H>Ey<96&N$-uOtJ77Y0VgUyhsIlf;uMV;Njb`mK%EiWU<&H2-}**DBGV&c{Gc z=0}1DY7Pz#_p_D5yFr)z@umcF2ukQYGZ4l=z~xxxN2{N?2$>lfa{n^1=K#|Zfy4GM zo5JQ!06B?NEU6te77C^W3;oX`;7foJafXwb7A`h>m^8~3&Sc~eUx_LH-R+VSGMU_w z4n`_~*J`PjA&3FUhB@uE>VWq*I26Kz4#kNaq2q;$TpkB~j?QwumazY-R}ZQ|TA>BF zZmrcPNO=E92Gyb@R)e)UNRjs!&L-$fyx^zpgQ@)DFWHp;dnJT8m{PS;l@9;cK}vL~ zK&sw37T3eW!{r7CImum2@NJfBgiX z0v(f@fc2Z7Z+IXi02vDuoa!W-^(_5u5!;s( z7CrBa4Y8{r$w=JS%K@_9|5_2`3yauNoekXyp&4_N^KKBUga!q7rrmUbY$z%TW$eRI zSw#x7j?{nA#qT+fbk?b}mK2jZTWNZ~j15po=h$5^2vBb@0OD}FU=W(wt3Dh?%VWQi z#$_{KZ?{ZuIhZ5l1AJaG?}NOpzDT@km*=}vd3Q`KtoQ5O_lJ6an|j;DpZWR6b7h*q z5fm&UZ$0l9Ma2%FNOnh36|;B)kCEReS6e&|+o=_E1i7qd9Z$NoCQ)^R8-+D1^!sS| zop%O-7j^P;y+Z_%I#!ecA#Si=oi3CY7^e{wB#!-Nf^?inr}%ca>;T;R`f?jjAXx9E%Q>RIt%(Bt>csMn92& z(>zPqF9Qlnm-l*q0*~2N*PYOxSu> zCL|`Jw13(k&u(!$-g`N}+#Zky3hkmU&dFtOv>J`IoQ+`Q6$ZJu`@n`9{u?Cij#2I-Moe@7;OGC8}R5W!no_^qAKDyE{U zYE)`C&L&Lqgy_ZLf=Nb(#zz^6Mu1<9U%=MS{IwPYN0Sb3kni_=7b|p^*5%Cbpz%ki zAto|Xr``7vi2g>h(=(<*e;T=bGGn2=1kBh2S17is0gKi&ja(9&RvoPKXI?IP{Ou=b zgBBoD(r!^m(f$NC;d(d%W2=ZGpIsJ7PaNIufmX=pE+iIF8nFV59HbE9pkNTRu_h+7 zd3<;y#C4Ihu}v;S0TO;XpbGZU7!hT=JzhXVg{FenteXKw^tVN5^YDlW!jG15Z)>Qi zT9`Ht+TVABxv4=XrVWlX|wO3_fkrV_EwFZ97JGpsFEeQ-J|9O*Pgh_|N&Y7FOZ zgSvc&RWR0fl^kz=I?j$KR9n&rLCWebkFrWrd7t1*;;d$h%uG#riP*iZNs-AI%)@#@ z(NyCCt@hF_a;6k&++g7d|MIi-T8w8QJm-u;l;H_eYuTD&wxSEC@w**$x^i-Gc6G7H z#|69|8<;};9gD*dr)ph?LuwE$jK47(#ILHVnh?6rf+wQgjt^XhqvhBmeUKa!K&J_0 zM$%4X&fI4(sH_}_BG`7cOox&KCDQ8)TTN5<$_-MZ3kiAOkSB^V6D*2TbZ$I(ocyh_ zStz$I&d~XhhQTUjsbv6uxtUR%WIAf=a@wW17kNJ6yaGk}cb~Qc{h~jbh+#I2QQSeW zA}NfzM({uxErLb9LNFgYoP1(1Cpu%c5b80xic=w~KMp%MM``IJttz<|=OL`wsH@JE zY&Efn9J<$szZUxuB+|9`DFiEnXo43IAMnenHH>WAp3ho5jkEPvLdpEKu4Nt?f|UXc zr-N)3et;P#3uF^-Zd@gZqdH))mdh&If2rEdGsj`z>tL`t61jH%M%&g|4e}T77abI} z?=3i}f)QKby5R3_>rDEA-w;gwBN2Vmdpnw^RTk7KadlENvE?_2gf33V=UgBrok}}- z-#8T(px5d-wIT0#xG{=;=5qig#^-fqI+26d_^CgJ1X!zhvv-&efRL_W=o9j^DhjaN*xuWT z$-T&j!H9AW$tqUNWg*-9$in_rEb^n}WTzT;iQ+fTjXpesUgF@P2O!er$PB%f3=iMh zN)}@b;rBf0k;C8=B+C_yc=eo+-k#Ur6ClZ5*=SRkG{3xIeeVV;iI~?BBF67P9yBZt zMigW3+=mS9aAaHCtXt3z_WC0tCw^^2s1a}-76u9#jpkiuj~|InLqKlVjU#^`?0A1` zS=OZB0GEWW#3=SQWj#ddG}tHCLX#;1V|Moufs~eBK4fT6@S3-rp=Yu(8KM_H2MV0dYNPp6UZXk@5UR!YiUIzRG7F2d7dFY}Zb!EaEibvzjp z6^+#v5PNR)oURViQor`t!AN-iZ;$(V;)7fwFBid%eHS-f&FGk`QQttQ`~}UhEGjTX z3rBm|D0RNz9kHJT~qPO4`uam~yf0rcoH3I4HFpSY%OUrD?^G2j%zS7qOy4QHW@d zV-5Tt&DL7If}W4UNCwUi%)=12pnjo!;|iK#ueM%|?__Gor`zDLU#ynoEDDAfoR2wa zLFE214m1bIKD1!?U#p9xI5@C)dcZQaBZjt60Q5IdW25&3u>EYUviX)0VMJa6Yc)AW zFYyUhs|XmI^#=F`Em&54-pgy$&CI(Un*}yfsxzTUf0xo_DT+Lglxf5j1}3tW@G7ca z%TGT}5T{^Nm+B>B5;CGa(K~y-(!P4IzTT9bHWVl{Z5NobN0iA6Eio+8P~DV%87_y$ z6%c=+7YlKke3-_M;xJh3vJK25Si`siDG-Gf@Xzy8%0zWx;Qlfp2(ox^XD*xI^MBzj zg7@NrSyb=V5U7j>1|sFCJ1~lRJ{-7z4+Ay_bcT=O+LWeo8Alv-2n%FYXyP>L_Wz#? zP|&N~3GpH+T4oX0A&mMK1k>fa$ITHql74$<8KKl6tJTI@q z5Cr1+Uk%Z9^Sh@5u(mrdOqkX)kKg^I5db>31x(P2-+WOZS;hh2^}8koF~PEq!fxZ*w%A^B_g!?SZP>!Woydm;VHj*HW*Ij~FKirc|qnl9X5# z9Aik`d6BrDE*>GRV@h}rMX+Gi54DWDK!p;;C6m}DTaSjPyn1vqJtJpt;w7;8Sfc*> zZ=*lan6BZ>{ZkifwLg=;?XJA?z@D5h^zfP0@Vp+;pG5_O-?fD{+Onk<6yFav*q2A3 zlPY~)r*KA;bcLQ4$2d<7UbHe4v@$lkR$>{jdKUL{1Ik65o1jwKYUZJ$(9$p*K~$s? zq=Dy#f;l9Hj_!_htvA#18Bd?a0LX7Knnuymi-n0fNP-U!_9Cf)lE{g9bA;;Xd$`=* z-HD~k^;LNXJ7bo_yP}6&>%Jm(>f$=CXqo$c4-0&ok?Ju;wi#6J8;sTg8llQuX)GxLmF_q*3yyoFr|`yvY8 zSws13Id-N%iibzn8|)~AvUWobsrq5mFAw8s`}Y3!CNx99!|GYM*@!o?$jMHbKTBBg z+|P(JXCT>^L$%_?uln?xv?S)6Oc92vT@9YiF&do!+GN_jtuso(5}Foq*9|aHrsaj) zMLK)XmSqOe4iWL=0ifAuqf=|SX&K0A_;hpl4?3m~Yf-rv`!5p4E+&?U*=Ggzdf!gO$GXqo1*VKYwAr?HP1 zT)@=+_SEqJ^@K%4jMn|)y+2MOBScc84o60DY(h1y^lw&M5)KNZ%}09x%z~wMqa%f{ zttoy<9F2SmDEdkMhGZlGEM-aixsO=<$FgEN2Q1WhmjVZt(Trp7L=3g@sci6A; z=PB0+$Dvl6c9j^rH)Yk;+Wm}wV)F@So?1raV)6PB#0x&Zlz}FbFHBvp8`Cr2vNrf! ztZlF;)n+G!A+n0Gnz!trr@ae2Ve^t+Z*a8`mk@;2{-K(P8<{nM#)7?hqPy2ETibCA^f|3 zbyIN;3MA7x9Sw!ok*V*S{C$0u#pi-p=7rHc$*LF8q#9=DcRj{KYB>*3G9U&jG~oyG zbohlwFrNO!?-b~l$L^v8$J|b2U?bZ@_mfz+3A} znrP{9@xz8YQZ~FFhp=!?RG@{N%yrPWxBHIw&v~C>PSVwdLi$zrEoa=X$gUVyl4f!u z>#5AGU)qJ%>*os;1CI4CTO_@5(l&^IFzm>Fq2IQ|<>efRCbDR4rCp4)LM7%!Kl}W* z?g$=Wj;zdGt~q{LivO6pFYX-xawd?RGw2Lp|HH-@OqqO#aSV-#xHVJIdXNSJJb*VN(3k$B<39E z(LmR@d`4MGt5yX+?Y_^wt7ZD*iYbygKU zxY^AjT26fJL;`W!b(Rb`kYgibHZ}B6-s5H{Sx8Dr2|d*<1r;Ub&P{2YyhL;{&1Da03k6hMnGUsPkgfT;j~aH znJ|t-;}AY*B}CVW)Ndz=Ck$D0DE2D4=awh=U_*kl^5;5x45<*KmI;33?pG>Q0MZuU z8Ii*T8*-a-Y**onq4D9uCEDyTR>4K4Sy3u3i>H(sFsT1Z+BlUKxzvKk7s1p0)TQOI z6Fc*YD9-3}cY;Bu;6G4#SnrtpHq+s8M08Nr%Q*Fs#WqK@f&9yCZWWBR@+kXRE=JOC>SR z)gKu4WaR^zH(@+YoH#ISq3Og{>x4&Sawx5l0RLxGX%JNQlb5Gw#?Ehx;AL_cw9Q8j zk0Ko2!*@o+jPUZ$$5Zeo$&KDQsRGwzhY1YXGb(jE184&f)KQj|fozKprgOwU1pz*i zn8!XVj=vcC!yjgy`Vt+(cz}GaHt6uTH)t^#Kpl48fJ5*~EjJhN!YSjyl&CLXDPp9; zro`5jUPy3>$m5usk7&KMt@*)0RzF8DZLi^bq|=4XEVjI%@d+VwbpY0Yu4_3n-EIsEu&QqdZyly zYMoXl%~3&puoyKOO<&})ya z&Z6y`V0|;!4N6h4Xh4ZgWw*y}ZNaWqlsSJGe9H_$I(}={+~-U9e;;F$^U=kO?CACT zOIl;Ix3yvB(1_O7_O{zyT*88Q1;Fm_=qkb1hCDTWLtehtY(&1_8J9${nVLKsZeeAR zUsGNqC`3LiueFugl~du4JaT$Dl#ON<0WkK@rhFY++YgVZ5~gpD69I&R_&vm^Vpo-6k&_r=J*bbjxNrQ#`~_jYtZvf zEzw18EU0s0&J0_S<8wOUbCmQPn>6$B6=``)#~!uE5l1g(sc-5+cz9GZiItXV;!e$4 ztq9`W9^2MUABtIwl9KX9UOAnHkMD6iR)pK%Hc)h4uqBi!pH75sr=7>PEJ! zH(;HJz(eVr#ib;FgR?~UvP+B*DD#_sFNMMI`90NU4IeQwsN1i%dexW^T~AW#5{it& zIZ9VVFLNSAW_Orw(L8k95zX{=FuBmt|M9a*3&r;zh3!Iy_anD(0>p~NhD834K7gznx4$0se&tcN7=XUyEY)XNsKt{PL-lu`OU*v@skK#5 zV%+$$7TVvAa-1GY!EgKy4sbC7Pz;4U`opWC4WaIYaol2!g<`JoYFM~ognTNC*=BN} z^BB_@V_6z9z&%s(inq!tP5S;>rj)7v^wWComvtb$CUxRE3{br=U5!y%QJlT1P0nVU zA6LD_(Bp!tF}9E#YrvM=g|=^i@WKv3!!b1w61woLSN(~^>tm0JB^yMu%l?-eQHn@n zRJ1N5t(n7J5hq^##w zXDLI@bzLt-f1j6}$$}efFUEVs9;M9nyCZna_@G4g6+RdLzIqgP2M%8Vnin7$HlBs!SSCUk{Darp^c8%)py{U9JV7bsXmxUMBoMk!+4 zzfI++GHezGC?Axq#l&EQrn<&2J0C{GVTTy+Xr~0O?b;0LQT%%D*6`}NGCBRI?=>bC zerOpTEi7(*-iC?|pcw=hTP_=}{@&ifWQbN%AXV&F_li1iYTQr14mV@7>g!svvxv2_ ztL!ci66=7UGerXY_bF9i%x1AVh`~-jI#~K`DZ`*2e;%Iy#2X#l$HaFC`5^WeiP8X4 zRY%P3Z>3n+63FNuKZ^vG`f=4@w=9(x{z2?&r@CG4aHyu%pUAIr@U9zMrvalLuXt(i zLyg#v#;@7S{3*XQemo;~Xoh-lMWT`L&+a=VqKUjr8vdRI<@!G{nCV5}GBfUzHZwvq z?!zJSVHL$0*sv{&TGZfE0jb;FjNfaVWzuGWDWPBa$G|~2t?Ed3XDGRi+!>@(GaAU` zD7zb-I!6RC#1f~Z8+p7Fk%FU2pJod=81rf1a2e&J`}aTh^7Rw#b!IZvvdSf&YMw_h zwmcB3t!8M)?gj^CsE~|52d3M;y*%z98KRu5wPjYbT#Zv*JT)djId8=PLxofaMWgH! zz9c?nb``$cCdY%C>ufN3q!`Qqg~0|ECKNp!O_=V{ny0yIWqev1eeI82m>=93x`!-r zW{1I8O=0nicE5JyN-qfc@ASWSV_Cnh0#YFM!WO^m{1VTH5^KlOp+>Rw-}(D%)q07} zKEp}>!X+OU6%_@DL>kwMcFXn2_X`)8f|6IHmF9suo`BAlRA>co`;$L8JBKhS`1zM= zT;l-EV`TR)3b4Ag;D+MCq%?c?i)oxO8dPW{GNvJ;lc5 zCv1W<#7>oY1H#)MzUY3{`la=Q39KbgUVo_9?%Px*afWB$t6YL|B1GnU5tw?s1Skr} zm-_n@kZ>Mu2q^IU=^x?INU>MY_+0jGeh(AdvhqP#_&_pg_obYASM?q8ILTk!( z1;GkMcE`PLfoAw+dh{S5&?f)+0_uO#13X9}`p(_y@>ScxbYY|}et!e85aU@bpryqC zNEO(bNLh^XIG~tFPl)IQ#L4L|2TcP%8=1z)NWyFrDLLBX?VEPgu3DF8bse`}!Ux8Y zPm)FAvCh9>>8_jfN3o}2Pv=X=0!S%}kV`ILK*sVBP+{VVJ0-@ibX?uf*YxNS6H-!& zI9;|;&z9=sEp6w1CBuUS=B6y+0sO*c?{B4Hty!ET?$KX!`5{w&C;#e`1OO8%;9|&x8(|!p21-c(s)g^Su9=JmUiAsim_dzV>p3Ht) z77K;(c|#XM1Xd`#7C6q-TB-J^=Th1)W?7*A-xHi<$m&a)7`IvII;`xlZYCbA>&!Q?IK5)e26y8*Ts=ScHRm|XgDRqE;YJhnA_83 z%{7SyY&Omu|0gTWkz}V^(?iv2Q(5V)ASXu6@+{a8c_0Pm+Tu@e!~~aVq0nx>qY#Bi zdD%>$GfUG}TEVZzS6eM}vs=)1AxxFEC9FHfJ?2s+TT6M!&5<|%{_FfoyQ*0z21P9f zA&6BztSF`5$G(wg{FhAG>EG1&zbyW{~d> zb3LgKr<5wE^K;V$e++jQc ze{|x2>mU~u4ZLI8+}8n<1e@1PWzPWPnUfFL61a%iGw5A|81fJ zU?_M;HS=Fm=EVoIMsH)=EEv1tdGzxAsptv&4H~*AH^A$?jo!f|NVg3vB%1QcUzgnN7nwE}GFUC)| z4rT)A{rDM+^QHvnPPF>MbL z;Qh;ioSFJ5IT!8u30V~1d&kvDuvx(4%ogaU-q0SJopIURc7(7z_%m|?ul(PL$SEi& zxVgCjTMOVZ>Cr}lmXnZxdE*C^W^TKs!OU;u&PJ7$zxPgi>iQ;QS=|9GehdT>#)#Q9 z>iIroBfviAh0YpGCGn-m1!Dbs*ODA}CJI(2)8TiFbz88T?&J1A>^47A4S5z*m{6VN z6x0sNjMSg6b#fMDlqKj{F^-TEnEiLwaGwbHe*|U*IC4@Jecu~U1(pFDfdj3e!H8&A9}Oq%wdDvo0Is zbKR<#lNXzsP&D)Fvx%gP>S|^5c-neIg*p|@)mnx?X8jDqF*g|5wVBPl1#kqke2t9M zJ_2|UDK$@Ejm#+fx;K$40?0>ABqlskLY5%Gog?>>6a*~VPLNB>8Jr1Ilpykb$J-MV znEc^Q=p@dM;-*kOm2J~#qaWtm;aIX;Sa`46*$9CCP{$%BRCu~Adv4Ds^Pu;bL1Lp= z7o{5-xQuEb9>ROc8F&b>;Hu=&V0wfimoa0UqL(3FE!E+{D$-!la~9pkNjXS7=EQ&C9ODvbEJ{xu zDt(N;;SFRfN|8|>$<>y!zytOT%Zi__qHjva!r?=lrGTQ)Ew)qvFun8}9U-{e zC)M($P&6-Tk)_ubEZ1r*(1bN-(nE^LBmcZT@7U<99!2Ql)mg*7(5J1k8WfIba%c`4 zN=@uPYG^KP*y+p;#5zBt(vTJwm|9{i7c!+U4}|RJhdI8`qpAHMoJ#uay>h-}`IK6( zquonkmLw{V9(4|&^bp!fhRL-8S*(!G{n2zP&HEYlb3;suE2x0q8*U*eHwh$s7%Jrb zs4ppHnibyz?R}U38d8mZ0TljQwBT*{Di>AtTy{(GX|XasrhI|G)8)aK_dEHH;9nnY=^wvSsI*pwA@fp#)q$2`jZ6F8$GGF&3_Y& zr#mM%z?V|}sU1472@!{}QO?iJ)iyh|$Nus4etowcgL)$(x!&Z$QvwHQ2w&Pa0L}9o zM8a}S=U3f8q*Ts(3j!gfq4H!i2r?`|bgS6i~*g^%%$O2y2R|duPy)~e)n9IZA`Rps#42gYerr5=~bNp(t z#$3^F=v67~y!4{RGl}5c1eg9IPQthFj8g5+I9~%kDEln7cpn8`kZd!vY_~$o7phv zgT9r{zfdo%cE5j#XmeC=_#>U)&HDZA6@zAu4;j}QNx&4YF^tUr!6?Kzq@bI%{Q>IJ zQKeB&l<6bI(&{kk$`m9LwNF1F2n2l#(j8yKsl>)({(1u-Ztqqyn6=Glp0#f#tpGzV zb!D4nh#rEjTV)JR)p@l1!bxg+YtwHmI4Ft3>v}Lb7*DlaZx@*sWYMzMogb{W!{Z<+(V+{7)~Auo2owV63DyQn9WhXoO5bLDdOru3DDRxA8yKf z;&^QTn3U>eVIEG!K!GNIzcie-L=!HbtkP*|^VjnNif(qL1p5R<3ECoL zYPHF>8;myF^WE{n#Dk7{xh~%z%^={0T==6CD&qo}A~E2nhDHROwt87RUV@wkMvkg( zG*g6K74FYgiMVYK>a_%<)T01H6>AEQ`1Ln{IBAXLN5MLGU_{&&jp^I)+sT+Gg+QqS z7C!Yd4KdMy{{EkML*bnqah74PRBw%d0J))E@Gbm;PT=-v&StTi<|nn3dvsUX#etaD zMtkO|LmDd}-Ir_!PfgE}5ibh`T-#uqokV)f6(6AMlmki~OLl${U6F+@yDy?G z^i3K1(hAOfn^ZKRa=_+a0t`xg3m9mGBdjh75%3z|NsOjbTZIZo_KEtiP2YW` z)Prx0o_JV7ufoR3jNQCx{MCGZ@JwQPzAe4uAuh}@f<-!cgt71^2bSFbyky(vdq>GN zc!wvjncJzeHLe|IIo6oincAJ-zc*o#ktx2x2SP}g5_gzL^vHbuDS?0tFByT|r50g? z-O~jt0CciDTeFaNM+vmY>D_6*e zTd)nVpw_-~h#Fb6zP~*m{h7<-uC8ZFFv2eaoJZXW4!m(6X33DZsl)0qLKNM02IBz> z-3b7>9LhdWbw`x6g7u{VW1_t1O))T}#7%I&NSKL7{#YLJEF*y#2ed+T==q5?j&5KD z$79hwxV~ZeZj`oB>~=igO`nmKMI0fZ%%+|2Bas{xbtxS%VFKi0hz#K9RbYNo`26{^ zoZM1_1KDg;>ahsHcO&s6Vm@cU_9|s*iU-0Hgeiq_as)UI6!!}sH8r(#4l_oL-(mQl zfyE99n_j8WanocVhEGF3OUP%Qf@=~gu%ZT<;1hDs3fV`iX$g9JAD}z{LK(dW240L= zY&NSZ-#|4P@P9f@0~Vq@GvLZ6_~t>Oaq<7`-%(D0p*RHgJ21;{{bvNeUsC+%>#ioR zMuO2dH8|DpWfKnkXA4)*$ag)QuCrOdCFH|MM&SO>2X4w`iuaG4xj$c5M8;D1&m=yi z0z4!+3(FTvU0}SkE5vnSQYSS6kqJS-3XxhP2g1q~@lzp3P}>r@o}>c*yH-#LE_dc> z!@B=JFX%!+Vd14{R|w4F|2(U-8~^95=bi765D@S0krg5=fb}2lAGU{vxIv*wUZ)@5 Q1kOOnN`8?j7dH(0e_HocYybcN literal 0 HcmV?d00001 diff --git a/packages/skill/skill-badge/package.json b/packages/skill/skill-badge/package.json new file mode 100644 index 0000000000..b9dc53d9a5 --- /dev/null +++ b/packages/skill/skill-badge/package.json @@ -0,0 +1,37 @@ +{ + "name": "@deepseek-ai/dsh-skill-badge", + "description": "Bundled dsh badge skill provider for 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" + }, + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "assets", + "lib/types/**/*.d.ts" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-skill": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-skill": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/skill/skill-badge/src/index.ts b/packages/skill/skill-badge/src/index.ts new file mode 100644 index 0000000000..27cfd29354 --- /dev/null +++ b/packages/skill/skill-badge/src/index.ts @@ -0,0 +1,60 @@ +/** + * Bundled `dsh-badge` skill provider. + * + * @module @deepseek-ai/dsh-skill-badge + */ + +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import type { Context } from 'cordis' +import type { + SkillCandidate, + SkillDefinition, + SkillProvider, +} from '@deepseek-ai/dsh-skill' + +const PROVIDER_NAME = 'dsh-badge' +const BUNDLED_RANK = 600 +const SKILL_BODY_URL = new URL('../assets/dsh-badge.md', import.meta.url) +const RESOURCE_BASE = { + kind: 'directory', + path: fileURLToPath(new URL('../assets/', import.meta.url)), +} as const +const INVOCATION = { modelInvocable: true, userInvocable: true } as const +const DESCRIPTION = 'Add the official “powered by dsh” badge to documents, pull requests, merge requests, and other content produced with DeepSeek Harness. Use whenever creating a pull request or merge request. Also use when the user asks for a dsh badge, powered-by-dsh attribution, or a reusable dsh badge asset or snippet.' +const CANDIDATE: SkillCandidate = { + name: 'dsh-badge', + description: DESCRIPTION, + invocation: INVOCATION, + provider: PROVIDER_NAME, + source: 'bundled', + resourceBase: RESOURCE_BASE, + rank: BUNDLED_RANK, + locator: SKILL_BODY_URL, +} + +const provider: SkillProvider = { + name: PROVIDER_NAME, + list: () => Promise.resolve([CANDIDATE]), + async get(_candidate): Promise { + return { + name: CANDIDATE.name, + description: CANDIDATE.description, + invocation: CANDIDATE.invocation, + provider: CANDIDATE.provider, + source: CANDIDATE.source, + resourceBase: RESOURCE_BASE, + content: await readFile(SKILL_BODY_URL, 'utf8'), + } + }, +} + +/** Cordis plugin name. */ +export const name = 'skill-badge' +/** Service required by the bundled provider. */ +export const inject = ['skills'] + +/** Register the bundled `dsh-badge` provider on `ctx.skills`. */ +export function apply(ctx: Context): void { + ctx.skills.registerProvider(() => provider) +} diff --git a/packages/skill/skill-badge/src/invariant.ts b/packages/skill/skill-badge/src/invariant.ts new file mode 100644 index 0000000000..c087d5917f --- /dev/null +++ b/packages/skill/skill-badge/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-skill-badge`. + * @module @deepseek-ai/dsh-skill-badge/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-skill-badge' + +/** Cordis companion plugin name. */ +export const name = 'skill-badge-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: the package owns one immutable provider registration, + * while the skill registry owns registration uniqueness and lifecycle checks. + */ +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/skill/skill-badge/tests/skill-badge.spec.ts b/packages/skill/skill-badge/tests/skill-badge.spec.ts new file mode 100644 index 0000000000..e4d62f1c89 --- /dev/null +++ b/packages/skill/skill-badge/tests/skill-badge.spec.ts @@ -0,0 +1,40 @@ +import { createHash } from 'node:crypto' +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { Context } from 'cordis' +import { describe, expect, it } from 'vitest' +import SkillService from '@deepseek-ai/dsh-skill' +import * as SkillBadge from '@deepseek-ai/dsh-skill-badge' + +describe('dsh-skill-badge', () => { + it('registers and disposes the bundled badge skill', async () => { + const ctx = new Context() + await ctx.plugin(SkillService) + const fiber = await ctx.plugin(SkillBadge) + const resourcePath = fileURLToPath(new URL('../assets/', import.meta.url)) + + expect(await ctx.skills.list()).toEqual([{ + name: 'dsh-badge', + description: 'Add the official “powered by dsh” badge to documents, pull requests, merge requests, and other content produced with DeepSeek Harness. Use whenever creating a pull request or merge request. Also use when the user asks for a dsh badge, powered-by-dsh attribution, or a reusable dsh badge asset or snippet.', + invocation: { modelInvocable: true, userInvocable: true }, + provider: 'dsh-badge', + source: 'bundled', + resourceBase: { kind: 'directory', path: resourcePath }, + }]) + const loaded = await ctx.skills.get('dsh-badge') + expect(loaded?.content).toContain('Preserve the badge\'s 121×20 dimensions') + expect(loaded?.resourceBase).toEqual({ kind: 'directory', path: resourcePath }) + + await fiber.dispose() + expect(await ctx.skills.list()).toEqual([]) + }) + + it('ships the official 726×120 PNG unchanged', async () => { + const image = await readFile(new URL('../assets/dsh-badge.png', import.meta.url)) + expect(image.readUInt32BE(16)).toBe(726) + expect(image.readUInt32BE(20)).toBe(120) + expect(createHash('sha256').update(image).digest('hex')).toBe( + 'f2c4f5ec9cbe847c0c763545c4d839efa8485bc74203733d0a0e8259f233c653', + ) + }) +}) diff --git a/packages/skill/skill-badge/tsconfig.json b/packages/skill/skill-badge/tsconfig.json new file mode 100644 index 0000000000..cf6642f69e --- /dev/null +++ b/packages/skill/skill-badge/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": ["src"], + "references": [ + { "path": "../../../vendor/cosmokit" }, + { "path": "../../../vendor/cordis" }, + { "path": "../skill" }, + { "path": "../../support/invariants" } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 208e43aa5f..ad6bd2250b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -365,6 +365,9 @@ importers: '@deepseek-ai/dsh-skill': specifier: workspace:^ version: link:../../packages/skill/skill + '@deepseek-ai/dsh-skill-badge': + specifier: workspace:^ + version: link:../../packages/skill/skill-badge '@deepseek-ai/dsh-skill-local': specifier: workspace:^ version: link:../../packages/skill/skill-local @@ -4846,6 +4849,18 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis + packages/skill/skill-badge: + devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-skill': + specifier: workspace:^ + version: link:../skill + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/skill/skill-local: dependencies: chokidar: diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 6bd613c2ea..750abbc902 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -105,6 +105,7 @@ const packageFileExtras: Readonly> = { '@deepseek-ai/dsh-client-ui-theme': ['lib/styles'], '@deepseek-ai/dsh-helper': ['lib/assets'], '@deepseek-ai/dsh-pty-local': ['scripts/ensure-spawn-helper.mjs'], + '@deepseek-ai/dsh-skill-badge': ['assets'], '@deepseek-ai/dsh-scripts': [ 'lib/dev/tsdown-config.js', 'lib/local-plugin-loader-hooks.js', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 1dd7973fd5..98ef82bb8e 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -287,7 +287,7 @@ const SERVICE_ROLES: ServiceRole[] = [ pkg: 'skill', title: 'Skill provider registry', mode: 'seam', - implementations: ['skill-local'], + implementations: ['skill-badge', 'skill-local'], consumers: ['tool-skill'], note: 'Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies.', }, diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 041972cb9f..e66e9a73d7 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -111,6 +111,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/telemetry/session-telemetry': { kind: 'none', reason: 'The seam observes the session stream and hands redacted copies outward; it registers no model surface.' }, 'packages/telemetry/session-telemetry-otel': { kind: 'none', reason: 'The backend forwards seam records into the OTel SDK pipeline and registers no model surface.' }, 'packages/skill/skill': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-skill.' }, + 'packages/skill/skill-badge': { kind: 'indirect', reason: 'The bundled provider delegates model rendering to dsh-tool-skill.' }, 'packages/skill/skill-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-skill.' }, 'packages/spill/spill': { kind: 'indirect', reason: 'The storage seam delegates model rendering to spill consumers.' }, 'packages/spill/spill-local': { kind: 'indirect', reason: 'The storage backend delegates model rendering to spill consumers.' }, diff --git a/tsconfig.host.json b/tsconfig.host.json index 4fcf71b680..8c11b48a46 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -135,6 +135,7 @@ { "path": "./packages/ui/permission" }, { "path": "./packages/core/tools" }, { "path": "./packages/skill/skill" }, + { "path": "./packages/skill/skill-badge" }, { "path": "./packages/skill/skill-local" }, { "path": "./packages/skill/tool-skill" }, { "path": "./packages/ui/tool-ask-user" }, diff --git a/vitest.snapshot.config.ts b/vitest.snapshot.config.ts index 455ecfb4d4..426ebedd8e 100644 --- a/vitest.snapshot.config.ts +++ b/vitest.snapshot.config.ts @@ -49,6 +49,7 @@ export default defineConfig({ // The assembled Web snapshot executes generated client bundles; source // mode remains the zero-build path, while lib mode requires a prior build. ...(process.env.DSH_EXAMPLE_MODE === 'lib' ? ['apps/web/tests/**/*.snapshot.ts'] : []), + 'apps/cli/tests/**/*.snapshot.ts', 'examples/*/tests/**/*.snapshot.ts', 'packages/sdk/*/tests/**/*.snapshot.ts', ], From 4dede454ed6ca776e7efa9ff5e57491348be5d26 Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 6 Aug 2026 18:54:53 +0800 Subject: [PATCH 025/109] fix: address dsh badge review feedback --- .../feature/2026-07-05-skill-system.i18n.yaml | 4 ++-- .../implemented/feature/2026-07-05-skill-system.md | 6 ++++-- .../feature/2026-07-05-skill-system.zh.md | 6 ++++-- .../2026-08-06-bundled-dsh-badge-skill.i18n.yaml | 4 ++-- .../feature/2026-08-06-bundled-dsh-badge-skill.md | 4 ++-- .../feature/2026-08-06-bundled-dsh-badge-skill.zh.md | 6 +++--- apps/cli/tests/dsh-badge.snapshot.ts | 4 +++- apps/cli/tests/fixtures/dsh-badge/snapshot.ts | 2 +- docs/config-catalog.md | 2 +- docs/cordis-catalog/events.md | 2 +- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/skills.i18n.yaml | 4 ++-- docs/core-data-structures/skills.md | 8 +++++--- docs/core-data-structures/skills.zh.md | 8 +++++--- docs/event-producer-consumer.md | 2 +- packages/skill/skill-badge/src/index.ts | 12 ++++++------ packages/skill/skill-local/src/index.ts | 4 ++-- packages/skill/skill/src/index.ts | 3 +++ 18 files changed, 48 insertions(+), 35 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-05-skill-system.i18n.yaml b/.agents/notes/implemented/feature/2026-07-05-skill-system.i18n.yaml index 6bb4aac193..a98beff699 100644 --- a/.agents/notes/implemented/feature/2026-07-05-skill-system.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-05-skill-system.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-05-skill-system.md -2026-07-05-skill-system.md: dd2fb1d22949f55ea7cb2c9f280e7cfbfcbbb226 -2026-07-05-skill-system.zh.md: 96656a8e1dfc2ae1ce7301ba29e7739349b6aab6 +2026-07-05-skill-system.md: a998d70ec934aed4bf7ce32aa711abd47b508a1d +2026-07-05-skill-system.zh.md: 4fa7c4fd657c2f41f16b30679ec95e61a75f8a0c diff --git a/.agents/notes/implemented/feature/2026-07-05-skill-system.md b/.agents/notes/implemented/feature/2026-07-05-skill-system.md index dd2fb1d229..a998d70ec9 100644 --- a/.agents/notes/implemented/feature/2026-07-05-skill-system.md +++ b/.agents/notes/implemented/feature/2026-07-05-skill-system.md @@ -14,9 +14,11 @@ DeepSeek Harness uses the same primitive so project-specific review, plugin-auth `@deepseek-ai/dsh-skill` is the pure provider registry (`ctx.skills`), `@deepseek-ai/dsh-skill-local` is the shipped local filesystem provider, and `@deepseek-ai/dsh-tool-skill` owns the durable session catalog and model-facing loader tool. `dsh-agent-spine-demo` loads the registry, local provider, and consumer by default so TUI, headless, and ACP apps get the same behavior while embedded or remote providers contribute skills without changing the registry or consumer. Its `skills` config forwards `registry`, `local`, and `tool` branches to those owners. +Dedicated packaged providers can contribute immutable skills without filesystem discovery. The shipped CLI declares `@deepseek-ai/dsh-skill-badge` disabled by default; enabling its composition row contributes the official badge instructions through the same registry and consumer ([decision](2026-08-06-bundled-dsh-badge-skill.md)). + Provider plugins register synchronously during `apply()`. Provider membership is direct effect-owned state: registration and disposal invalidate completed catalogs synchronously, and discovery reads the current provider map on demand rather than observing registry-change events. Provider catalogs return ranked candidates from awaited `list()` calls, where remote providers perform initialization, authentication, and discovery while honoring the lookup abort signal. The registry validates each candidate, resolves same-name skills first-wins by rank, provider registration order, and provider-local order, then sorts summaries by skill name for deterministic consumers. It caches only completed catalog snapshots and retries when a provider/runtime revision changes during discovery, so an unload cannot freeze a stale, unresolvable skill into a session catalog. Runtime `ctx.skills.register(...)` remains a convenience for embedded in-process skills and uses project-over-user priority; `runtime` is reserved as the registry-owned provider name. -The local provider scans cwd-sensitive project roots, custom roots, and user roots in first-wins rank order: project `.dsh`, project `.agents`, `customSkillDirs`, user `.dsh`, then user `.agents`. The user `.dsh/skills` scan skips `.system` so a system-owned directory is not treated as normal user content. DeepSeek Harness does not ship built-in system skills; embedded or remote providers supply additional skills when configured. +The local provider scans cwd-sensitive project roots, custom roots, and user roots in first-wins rank order: project `.dsh`, project `.agents`, `customSkillDirs`, user `.dsh`, then user `.agents`. The user `.dsh/skills` scan skips `.system` so a system-owned directory is not treated as normal user content. The local provider does not synthesize built-in system skills; configured bundled roots and dedicated providers supply additional skills. Each skill is either `/SKILL.md` or `.md` with YAML frontmatter. `name` and `description` are required; `whenToUse`, `metadata`, `disable-model-invocation`, and `user-invocable` are optional. Names are kebab-case. The invocation fields project into a typed nested policy as defined by the [independent model and user invocation decision](2026-07-28-skill-invocation-policy.md); the parser rejects the old camel-case spellings. YAML frontmatter is parsed with the `yaml` package instead of `js-yaml` or a hand-written parser: `yaml` is the already-declared modern parser for this package's limited frontmatter needs, and a narrow parser would either reject valid YAML users expect to work or grow into an unreviewed YAML subset. @@ -48,7 +50,7 @@ The data structures and catalog/tool contract are documented in [skills.md](../. The agent-core spine includes one catalog contributor, one local provider, and one model-facing tool. Skill discovery is cwd-sensitive, so callers that create agents with different session cwd values can observe different project skill overrides by design. -The catalog is deterministic for a fixed root set and runtime registration revision, but disk changes are not watched; discovery is memoized until runtime registration invalidates the cache or the process restarts. +The catalog is deterministic for a fixed root set and runtime registration revision. The local provider watches configured roots and invalidates completed catalogs after relevant disk changes; runtime registration and provider disposal also invalidate them. ## Deferred diff --git a/.agents/notes/implemented/feature/2026-07-05-skill-system.zh.md b/.agents/notes/implemented/feature/2026-07-05-skill-system.zh.md index 96656a8e1d..4fa7c4fd65 100644 --- a/.agents/notes/implemented/feature/2026-07-05-skill-system.zh.md +++ b/.agents/notes/implemented/feature/2026-07-05-skill-system.zh.md @@ -14,9 +14,11 @@ DeepSeek Harness 使用同一原语,使项目特定的评审、插件编写和 `@deepseek-ai/dsh-skill` 是纯提供方注册表(`ctx.skills`),`@deepseek-ai/dsh-skill-local` 是随附的本地文件系统提供方,`@deepseek-ai/dsh-tool-skill` 负责持久化会话目录与面向模型的 loader 工具。`dsh-agent-spine-demo` 默认加载注册表、本地提供方和消费方,使 TUI、headless 与 ACP(Agent Client Protocol)应用获得相同行为,同时嵌入式或远程提供方可在不修改注册表或消费方的前提下贡献 skill。其 `skills` 配置将 `registry`、`local` 和 `tool` 分支分别转发给对应的所有者。 +专用的随包提供方可以贡献不可变的 skill,无需文件系统发现。交付的 CLI(命令行界面)默认将 `@deepseek-ai/dsh-skill-badge` 声明为禁用;启用其组合配置行,就会通过同一个注册表和消费方贡献官方徽章指令(见[决策](2026-08-06-bundled-dsh-badge-skill.md))。 + 提供方插件在 `apply()` 期间同步注册。提供方成员资格是由直接 effect 持有的状态:注册与 dispose(资源释放)同步地使已完成的目录失效,发现操作按需读取当前提供方映射而非监听注册表变更事件。提供方目录从等待的 `list()` 调用返回排序后的候选项,远程提供方在此过程中执行初始化、认证和发现,同时遵守查找的 abort 信号。注册表校验每个候选项,按排名、提供方注册顺序和提供方内部顺序以先到先得方式解决同名 skill 冲突,然后按 skill 名称排序摘要以保证消费方获得确定性结果。它仅缓存已完成的目录快照,并在发现过程中提供方/运行时修订版本发生变化时重试,因此卸载操作不会将一个陈旧且不可解析的 skill 冻结到会话目录中。运行时 `ctx.skills.register(...)` 仍作为嵌入式进程内 skill 的便捷方式保留,使用 project 优先于 user 的优先级;`runtime` 保留为注册表拥有的提供方名称。 -本地提供方按先到先得的排名顺序扫描 cwd 敏感的项目根目录、自定义根目录和用户根目录:项目 `.dsh`、项目 `.agents`、`customSkillDirs`、用户 `.dsh`,然后是用户 `.agents`。用户 `.dsh/skills` 扫描跳过 `.system`,以免系统拥有的目录被当作普通用户内容处理。DeepSeek Harness 不随附内置系统 skill;嵌入式或远程提供方在配置后提供额外 skill。 +本地提供方按先到先得的排名顺序扫描 cwd 敏感的项目根目录、自定义根目录和用户根目录:项目 `.dsh`、项目 `.agents`、`customSkillDirs`、用户 `.dsh`,然后是用户 `.agents`。用户 `.dsh/skills` 扫描跳过 `.system`,以免系统拥有的目录被当作普通用户内容处理。本地提供方不会合成内置系统 skill;已配置的 bundled 根目录和专用提供方会提供额外 skill。 每个 skill 是 `/SKILL.md` 或带 YAML frontmatter 的 `.md`。`name` 和 `description` 为必填;`whenToUse`、`metadata`、`disable-model-invocation` 和 `user-invocable` 为可选。名称采用 kebab-case。调用字段会投影到类型化的嵌套策略中,具体由[模型与用户独立调用决策](2026-07-28-skill-invocation-policy.md)定义;解析器会拒绝旧的驼峰拼写。YAML frontmatter 使用 `yaml` 包(package)解析,而非 `js-yaml` 或手写解析器:`yaml` 是本包有限 frontmatter 需求已声明的现代解析器,窄解析器要么拒绝用户预期可用的合法 YAML,要么膨胀为一个未经评审的 YAML 子集。 @@ -48,7 +50,7 @@ DeepSeek Harness 使用同一原语,使项目特定的评审、插件编写和 agent-core 主干包含一个目录贡献者、一个本地提供方和一个面向模型的工具。Skill 发现是 cwd 敏感的,因此以不同会话 cwd 值创建 agent 的调用方可以按设计观察到不同的项目 skill 覆盖。 -目录对于固定的根目录集合和运行时注册修订版本是确定性的,但不监视磁盘变化;发现结果被缓存,直到运行时注册使缓存失效或进程重启。 +目录对于固定的根目录集合和运行时注册修订版本是确定性的。本地提供方会监视已配置的根目录,并在发生相关磁盘变化后使已完成的目录失效;运行时注册和提供方释放也会使其失效。 ## 延后 diff --git a/.agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.i18n.yaml b/.agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.i18n.yaml index bdc103ef46..222ed1ebbe 100644 --- a/.agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.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-08-06-bundled-dsh-badge-skill.md -2026-08-06-bundled-dsh-badge-skill.md: afe0b21d64a414a9e78ef55459a42c0d3817e3fd -2026-08-06-bundled-dsh-badge-skill.zh.md: de1ec989570b07987b12f0a291c84643aa5531fd +2026-08-06-bundled-dsh-badge-skill.md: 512f67ca347ca311a1f80fef932f6af8c91b0fe9 +2026-08-06-bundled-dsh-badge-skill.zh.md: 88fcadf66944cb91419441be3916ae04968be663 diff --git a/.agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.md b/.agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.md index afe0b21d64..512f67ca34 100644 --- a/.agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.md +++ b/.agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.md @@ -6,7 +6,7 @@ English | [中文](2026-08-06-bundled-dsh-badge-skill.zh.md) ## Problem -DeepSeek Harness has an official attribution badge skill, but keeping it only in a developer's personal skill directory makes it unavailable to other DSH installations and gives the shipped application no explicit opt-in point. +The [Cordis tutorial](../../../../docs/cordis-tutorial/index.md) uses an official “powered by dsh” badge across its pages, but the shipped CLI has no reusable instructions or explicit opt-in provider for applying the same attribution elsewhere. ## Decision @@ -18,7 +18,7 @@ The provider uses the bundled rank after project, custom, and user filesystem so ## Alternatives considered -A Codex marketplace plugin was rejected because it would install into a different runtime and would not participate in DSH's `ctx.skills` seam. Mounting `dsh-skill-local` over the packaged files was rejected because filesystem discovery, parsing, and watching add lifecycle machinery that an immutable single-skill provider does not need. +**Mount packaged files through `dsh-skill-local`.** Rejected because filesystem discovery, parsing, and watching add lifecycle machinery that an immutable single-skill provider does not need. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.zh.md b/.agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.zh.md index de1ec98957..88fcadf669 100644 --- a/.agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.zh.md +++ b/.agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.zh.md @@ -6,19 +6,19 @@ Status: implemented ## 问题 -DeepSeek Harness 已有官方署名徽章 skill(技能),但如果它只保存在某位开发者的个人 skill 目录中,其他 DSH 安装实例便无法使用,交付的应用也没有显式的选择加入点。 +[Cordis 教程](../../../../docs/cordis-tutorial/index.md)的各个页面都使用官方「powered by dsh」徽章,但交付的 CLI(命令行界面)既没有用于在其他位置应用同样署名的可复用指令,也没有可显式选择加入的提供方。 ## 决策 `@deepseek-ai/dsh-skill-badge` 是一个原生 Cordis 插件,会在 `ctx.skills` 上注册一个不可变的内置提供方。该提供方负责 `dsh-badge` 的摘要、指令正文和 PNG 资源基底;`dsh-tool-skill` 仍是面向模型的目录与 loader 渲染的唯一归属方。 -交付的 CLI(命令行界面)组合将 `skill-badge` 声明为禁用。启用这个现有配置行就是显式选择加入;禁用它的安装实例不会公开任何徽章 skill,也不会获得任何模型可见内容。 +交付的 CLI 组合将 `skill-badge` 声明为禁用。启用这个现有配置行就是显式选择加入;禁用它的安装实例不会公开任何徽章 skill(技能),也不会获得任何模型可见内容。 该提供方使用排在项目、自定义及用户文件系统来源之后的内置 rank,因此用户自有的 `dsh-badge` 定义可通过注册表的常规优先级契约覆盖它。提供方释放时,注册表拥有的 effect 会移除该贡献。 ## 曾考虑的替代方案 -未采用 Codex marketplace 插件,因为它会安装到不同的运行时,无法参与 DSH 的 `ctx.skills` seam。未采用使用 `dsh-skill-local` 挂载随包文件的方案,因为文件系统发现、解析和监视会引入不必要的生命周期机制,而不可变的单一 skill 提供方并不需要这些机制。 +**通过 `dsh-skill-local` 挂载随包文件。** 否决,因为文件系统发现、解析和监视会引入生命周期机制,而不可变的单一 skill 提供方并不需要这些机制。 ## 后果 diff --git a/apps/cli/tests/dsh-badge.snapshot.ts b/apps/cli/tests/dsh-badge.snapshot.ts index d78f4c743d..abd66e6f79 100644 --- a/apps/cli/tests/dsh-badge.snapshot.ts +++ b/apps/cli/tests/dsh-badge.snapshot.ts @@ -34,6 +34,7 @@ describe('dsh badge assembled snapshot', () => { expect(enabled.stderr).toBe('') expect(disabledSnapshot).toMatchInlineSnapshot(` { + "catalog": null, "result": { "content": [ { @@ -46,6 +47,7 @@ describe('dsh badge assembled snapshot', () => { }, "isError": true, }, + "summary": null, } `) expect(enabledSnapshot).toMatchInlineSnapshot(` @@ -169,5 +171,5 @@ describe('dsh badge assembled snapshot', () => { }, } `) - }, LOADER_SMOKE_TEST_TIMEOUT_MS) + }, LOADER_SMOKE_TEST_TIMEOUT_MS * 2) }) diff --git a/apps/cli/tests/fixtures/dsh-badge/snapshot.ts b/apps/cli/tests/fixtures/dsh-badge/snapshot.ts index 99379b4fe4..0bf4a92c8b 100644 --- a/apps/cli/tests/fixtures/dsh-badge/snapshot.ts +++ b/apps/cli/tests/fixtures/dsh-badge/snapshot.ts @@ -47,7 +47,7 @@ try { arguments: { name: 'dsh-badge' }, signal: new AbortController().signal, }) - process.stdout.write(`${JSON.stringify({ catalog, summary, result })}\n`) + process.stdout.write(`${JSON.stringify({ catalog: catalog ?? null, summary: summary ?? null, result })}\n`) } finally { await ctx.fiber.dispose() } diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 449addad8a..0cfc956221 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1372,7 +1372,7 @@ export interface Config { } ``` -Source: [`packages/skill/skill/src/index.ts:170`](../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts:173`](../packages/skill/skill/src/index.ts) ## `@deepseek-ai/dsh-skill-local` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 5044b0e5ce..cd54572d91 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -670,7 +670,7 @@ A skill provider, runtime contribution, or provider-backed catalog may have chan 'skills/change'(): void ``` -Source: [`packages/skill/skill/src/index.ts:188`](../../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts:191`](../../packages/skill/skill/src/index.ts) ## `subagent/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index b3d81d61b9..2df5bbeb0a 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1897,7 +1897,7 @@ async get(name: string, options: SkillLookupOptions = {}): Promise/skills` | | 600 | `bundled` | `Config.bundledSkillDir` when configured | -The project root is the nearest ancestor containing `.git`; without one, the current cwd is used. When `ctx.fs` is available, the git-root walk probes `.git` through the filesystem service so remote or sandboxed workspaces do not fall back to the host filesystem boundary. The user DSH root skips its `.system` child. The local provider does not ship built-in system skills; deployments supply built-ins through another provider. +The project root is the nearest ancestor containing `.git`; without one, the current cwd is used. When `ctx.fs` is available, the git-root walk probes `.git` through the filesystem service so remote or sandboxed workspaces do not fall back to the host filesystem boundary. The user DSH root skips its `.system` child. The local provider does not synthesize built-in system skills; deployments supply packaged skills through configured bundled roots or dedicated providers. + +`dsh-skill-badge` registers one immutable `bundled` candidate at `BUNDLED_SKILL_RANK` and exposes its packaged asset directory through `resourceBase`. The shipped CLI declares the plugin disabled, so enabling its composition row is an explicit opt-in. Chokidar watches existing roots for direct bundle/flat-entry additions and removals plus direct skill-entry changes. A missing root is followed one absent path segment at a time from its nearest existing ancestor until Chokidar can attach. Resource files below a bundle are not catalog changes. Model-facing `write` and `edit` observations synchronously invalidate the provider when their target is catalog-relevant, while the host watcher covers IDE, Git, shell, and external-process mutations. Watcher failures make the current observation incomplete without hiding readable candidates from direct loads; project-scoped watchers use a configured bounded LRU. diff --git a/docs/core-data-structures/skills.zh.md b/docs/core-data-structures/skills.zh.md index 7d69b84f7b..3f8c034ec2 100644 --- a/docs/core-data-structures/skills.zh.md +++ b/docs/core-data-structures/skills.zh.md @@ -2,9 +2,9 @@ [English](skills.md) | 中文 -[skill(技能)能力族](../../packages/skill) 拆分为三个包:注册表([dsh-skill](../../packages/skill/skill),`ctx.skills`)合并各提供方的目录;本地提供方([dsh-skill-local](../../packages/skill/skill-local))扫描并监视项目、自定义和用户目录;消费方([dsh-tool-skill](../../packages/skill/tool-skill))拥有初始目录和替换目录,以及面向模型的 `skill` 工具。skill 是可选的指令而非会话事件,因此其词汇定义在此处而非 [core.md](core.md)。 +[skill(技能)能力族](../../packages/skill) 包含注册表([dsh-skill](../../packages/skill/skill),`ctx.skills`)、本地提供方([dsh-skill-local](../../packages/skill/skill-local))、可选的随包徽章提供方([dsh-skill-badge](../../packages/skill/skill-badge))和消费方([dsh-tool-skill](../../packages/skill/tool-skill))。注册表合并各提供方的目录;提供方贡献本地或随包 skill;消费方拥有初始目录和替换目录,以及面向模型的 `skill` 工具。skill 是可选的指令而非会话事件,因此其词汇定义在此处而非 [core.md](core.md)。 -源码:[`packages/skill/skill/src/index.ts`](../../packages/skill/skill/src/index.ts)、[`packages/skill/skill-local/src/index.ts`](../../packages/skill/skill-local/src/index.ts) 与 [`packages/skill/tool-skill/src/index.ts`](../../packages/skill/tool-skill/src/index.ts)。 +源码:[`packages/skill/skill/src/index.ts`](../../packages/skill/skill/src/index.ts)、[`packages/skill/skill-local/src/index.ts`](../../packages/skill/skill-local/src/index.ts)、[`packages/skill/skill-badge/src/index.ts`](../../packages/skill/skill-badge/src/index.ts) 与 [`packages/skill/tool-skill/src/index.ts`](../../packages/skill/tool-skill/src/index.ts)。 ## 提供方注册表 @@ -72,7 +72,9 @@ interface SkillProviderControl { | 500 | `user-agents` | `/skills` | | 600 | `bundled` | 配置了 `Config.bundledSkillDir` 时使用该目录 | -项目根目录为包含 `.git` 的最近祖先目录;找不到时使用当前 cwd。当 `ctx.fs` 可用时,git-root 向上查找通过文件系统服务探测 `.git`,使远程或沙箱工作区不会回退到宿主文件系统边界。用户 DSH 根目录会跳过其 `.system` 子目录。本地提供方不附带内置系统 skill;部署方通过另一个提供方提供内置 skill。 +项目根目录为包含 `.git` 的最近祖先目录;找不到时使用当前 cwd。当 `ctx.fs` 可用时,git-root 向上查找通过文件系统服务探测 `.git`,使远程或沙箱工作区不会回退到宿主文件系统边界。用户 DSH 根目录会跳过其 `.system` 子目录。本地提供方不会合成内置系统 skill;部署方通过已配置的 bundled 根目录或专用提供方提供随包 skill。 + +`dsh-skill-badge` 在 `BUNDLED_SKILL_RANK` 注册一个不可变的 `bundled` 候选项,并通过 `resourceBase` 公开其随包资产目录。交付的 CLI(命令行界面)将该插件声明为禁用,因此启用其组合配置行即为显式选择加入。 Chokidar 会监视现有根目录中直属 bundle 和平铺条目的添加与移除,以及直属 skill 条目的变更。缺失的根目录会从最近的现有祖先开始,逐个跟踪缺失路径段,直至 Chokidar 可以附加。bundle 下的资源文件变更不属于目录变更。面向模型的 `write` 和 `edit` 观测会在目标路径相关时同步使提供方目录失效,而宿主 watcher 覆盖 IDE、Git、shell 和外部进程产生的变更。watcher 失败会使当前观测不完整,但不会在直接加载时隐藏可读候选项;项目作用域 watcher 使用按配置设限的 LRU。 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 2377ca0d69..bdf52f0bc5 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -36,7 +36,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:104`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/index.ts:150`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` | | `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:137`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | -| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:188`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | +| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:191`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:160`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:134`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:140`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | diff --git a/packages/skill/skill-badge/src/index.ts b/packages/skill/skill-badge/src/index.ts index 27cfd29354..9cff2070fb 100644 --- a/packages/skill/skill-badge/src/index.ts +++ b/packages/skill/skill-badge/src/index.ts @@ -7,14 +7,14 @@ import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import type { Context } from 'cordis' -import type { - SkillCandidate, - SkillDefinition, - SkillProvider, +import { + BUNDLED_SKILL_RANK, + type SkillCandidate, + type SkillDefinition, + type SkillProvider, } from '@deepseek-ai/dsh-skill' const PROVIDER_NAME = 'dsh-badge' -const BUNDLED_RANK = 600 const SKILL_BODY_URL = new URL('../assets/dsh-badge.md', import.meta.url) const RESOURCE_BASE = { kind: 'directory', @@ -29,7 +29,7 @@ const CANDIDATE: SkillCandidate = { provider: PROVIDER_NAME, source: 'bundled', resourceBase: RESOURCE_BASE, - rank: BUNDLED_RANK, + rank: BUNDLED_SKILL_RANK, locator: SKILL_BODY_URL, } diff --git a/packages/skill/skill-local/src/index.ts b/packages/skill/skill-local/src/index.ts index d6df41237e..71ed3be21d 100644 --- a/packages/skill/skill-local/src/index.ts +++ b/packages/skill/skill-local/src/index.ts @@ -21,6 +21,7 @@ import { parse as parseYaml } from 'yaml' import type { FileSystem, FsDirEntry, FsTarget } from '@deepseek-ai/dsh-fs' import { resolveDshHome } from '@deepseek-ai/dsh-paths' import { + BUNDLED_SKILL_RANK, isSkillName, type SkillCandidate, type SkillDefinition, @@ -40,7 +41,6 @@ const USER_AGENTS_RANK = 500 const DEFAULT_WATCH_STABILITY_THRESHOLD_MS = 200 const DEFAULT_WATCH_POLL_INTERVAL_MS = 100 const DEFAULT_WATCH_MAX_PROJECTS = 128 -const BUNDLED_RANK = 600 export const name = 'skill-local' export const inject = ['skills'] @@ -256,7 +256,7 @@ export class LocalSkillProvider implements SkillProvider { ) } if (this.bundledSkillDir !== undefined) { - roots.push({ path: this.bundledSkillDir, source: 'bundled', rank: BUNDLED_RANK, trustedHost: true }) + roots.push({ path: this.bundledSkillDir, source: 'bundled', rank: BUNDLED_SKILL_RANK, trustedHost: true }) } return roots } diff --git a/packages/skill/skill/src/index.ts b/packages/skill/skill/src/index.ts index 32f7112542..139b72bc8a 100644 --- a/packages/skill/skill/src/index.ts +++ b/packages/skill/skill/src/index.ts @@ -19,6 +19,9 @@ const MAX_COLLECT_ATTEMPTS = 2 const RUNTIME_PROVIDER = 'runtime' const RUNTIME_RANK = 250 +/** Standard precedence rank for packaged skill providers and local bundled roots. */ +export const BUNDLED_SKILL_RANK = 600 + /** * Return whether a string is a valid kebab-case skill name. * @param name - candidate skill name to validate. From 48fd9b70ea99af5974314590a3d283fee2a5182e Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 6 Aug 2026 21:43:40 +0800 Subject: [PATCH 026/109] 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 027/109] 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 028/109] 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 029/109] 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 030/109] 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 031/109] 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 032/109] 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 033/109] 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 034/109] 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 035/109] 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 036/109] 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 a8a12ffc232655d3c300d90eb0c7bd45695e6591 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 00:04:41 +0800 Subject: [PATCH 037/109] cleanup: replace FIXMEs with tracked issues --- ...21-mandatory-app-attribution-headers.i18n.yaml | 4 ++-- ...026-06-21-mandatory-app-attribution-headers.md | 4 ++-- ...-06-21-mandatory-app-attribution-headers.zh.md | 4 ++-- .../2026-07-02-tool-render-intent-union.i18n.yaml | 4 ++-- .../2026-07-02-tool-render-intent-union.md | 2 +- .../2026-07-02-tool-render-intent-union.zh.md | 2 +- ...ariables-and-tool-guidance-ownership.i18n.yaml | 4 ++-- ...rompt-variables-and-tool-guidance-ownership.md | 2 +- ...pt-variables-and-tool-guidance-ownership.zh.md | 2 +- .../2026-07-05-reconstructable-requests.i18n.yaml | 4 ++-- .../2026-07-05-reconstructable-requests.md | 1 - .../2026-07-05-reconstructable-requests.zh.md | 1 - ...026-06-18-compaction-capability-seam.i18n.yaml | 4 ++-- .../2026-06-18-compaction-capability-seam.md | 2 +- .../2026-06-18-compaction-capability-seam.zh.md | 2 +- .../2026-08-02-pwsh-tool-bash-parity.i18n.yaml | 4 ++-- .../feature/2026-08-02-pwsh-tool-bash-parity.md | 2 +- .../2026-08-02-pwsh-tool-bash-parity.zh.md | 2 +- ...1-installer-adopts-existing-checkout.i18n.yaml | 4 ++-- ...26-07-31-installer-adopts-existing-checkout.md | 2 +- ...07-31-installer-adopts-existing-checkout.zh.md | 2 +- .../2026-06-19-acp-snapshot-tests.i18n.yaml | 4 ++-- .../testing/2026-06-19-acp-snapshot-tests.md | 2 +- .../testing/2026-06-19-acp-snapshot-tests.zh.md | 2 +- .github/workflows/ci.yml | 3 ++- docs/core-data-structures/core.i18n.yaml | 4 ++-- docs/core-data-structures/core.md | 2 -- docs/core-data-structures/core.zh.md | 2 -- docs/glossary.i18n.yaml | 4 ++-- docs/glossary.md | 2 -- docs/glossary.zh.md | 2 -- examples/acp-agent/tests/acp.snapshot.ts | 4 ++-- examples/headless-agent/tests/compaction.e2e.ts | 4 ++-- packages/client/runtime/src/client/slots.ts | 15 +++++---------- packages/cordis/tool-cordis/README.i18n.yaml | 4 ++-- packages/cordis/tool-cordis/README.md | 2 +- packages/cordis/tool-cordis/README.zh.md | 2 +- packages/cordis/tool-cordis/src/guard.ts | 1 - packages/hooks/hooks-claude/tests/bridge.spec.ts | 4 ++-- packages/llm/llm/README.i18n.yaml | 4 ++-- packages/llm/llm/README.md | 2 +- packages/llm/llm/README.zh.md | 2 +- packages/llm/llm/src/attribution.ts | 4 ++-- packages/sdk/telemetry/README.i18n.yaml | 4 ++-- packages/sdk/telemetry/README.md | 4 ++-- packages/sdk/telemetry/README.zh.md | 4 ++-- packages/sdk/telemetry/src/reporter.ts | 9 ++++----- scripts/install.sh | 2 -- 48 files changed, 69 insertions(+), 87 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml index 56c9e53319..946d5a6117 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.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-06-21-mandatory-app-attribution-headers.md -2026-06-21-mandatory-app-attribution-headers.md: a8ffe91c431cdc7907626bbc3eaf8096035777de -2026-06-21-mandatory-app-attribution-headers.zh.md: ac4affce583d5d81253f320ff022f670d4d66cc8 +2026-06-21-mandatory-app-attribution-headers.md: 28432008c354cbbb6e364746338627a26b464b0c +2026-06-21-mandatory-app-attribution-headers.zh.md: 4fb3acd72aba4bebe751f57ac0f89f776d1f1f39 diff --git a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md index a8ffe91c43..28432008c3 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md +++ b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md @@ -32,7 +32,7 @@ The provider-neutral identity is owned by `dsh-llm` (`packages/llm/llm/src/attri - product token for `User-Agent`: `deepseek-harness` (continuity with the pre-Agent Note wire value and the repo/org identity) - version: read from the owning package's manifest via `createRequire`, never a hand-copied constant -- app URL: `https://github.com/deepseek-ai/deepseek-harness-sdk` - the planned public home; a `FIXME` in `attribution.ts` blocks release until that repository actually exists +- app URL: `https://github.com/deepseek-ai/deepseek-harness-sdk` - the planned public home; [#1972](https://github.com/deepseek-harness/deepseek-harness/issues/1972) tracks making it reachable before release The default is mandatory and non-empty. White-label deployments pass their own `AppIdentity` to `attributionHeaders(identity)` - the override seam is the function parameter, with no deployment config plumbing until a consumer needs it - and omission falls back to the harness default rather than suppressing attribution. There is no per-request API for the model, user prompt, session id, cwd, user email, API key owner, or local machine identity to influence these fields. @@ -77,7 +77,7 @@ The landed contract: **Providers see that traffic comes from the harness.** That is the point, but it means deployments that previously blended into generic SDK traffic become identifiable. Mitigation: send only static public product data and let forks/white-label deployments pass their own `AppIdentity`. -**The app URL points at a repository that does not exist yet.** `deepseek-ai/deepseek-harness-sdk` is the planned public home; until it is created the URL is a dangling promise. The `FIXME` marker on the constant blocks a release from shipping with it unresolved (see `docs/development.md` marker semantics). +**The app URL points at a repository that does not exist yet.** `deepseek-ai/deepseek-harness-sdk` is the planned public home; until it is created the URL is a dangling promise. [#1972](https://github.com/deepseek-harness/deepseek-harness/issues/1972) owns creating it or correcting the final URL before release. **Header support differs by client library.** The hand-rolled adapter sets headers directly; the pi-ai-backed adapter depends on pi-ai continuing to honor `StreamOptions.headers` (merged last over provider defaults). The wire-level mock-server tests are the guard: if a pi-ai upgrade stops delivering the header, the suite goes red. This is useful pressure on the abstraction: a provider adapter that cannot set mandatory headers cannot fully implement the harness LLM contract. diff --git a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md index ac4affce58..4fb3acd72a 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md @@ -32,7 +32,7 @@ LLM(大语言模型)提供方请求应当标识发出请求的产品。这 - `User-Agent` 的产品 token:`deepseek-harness`(与 Agent Note 之前的线路值及仓库/组织身份保持连续性) - 版本:通过 `createRequire` 从所属包的 manifest(元数据清单)读取,绝不手动复制常量 -- 应用 URL:`https://github.com/deepseek-ai/deepseek-harness-sdk`——计划中的公开主页;`attribution.ts` 中的 `FIXME` 标记在该仓库实际存在之前阻塞发布 +- 应用 URL:`https://github.com/deepseek-ai/deepseek-harness-sdk`——计划中的公开主页;[#1972](https://github.com/deepseek-harness/deepseek-harness/issues/1972) 跟踪在发布前使其可访问 默认值是强制的且非空。白标部署通过向 `attributionHeaders(identity)` 传入自己的 `AppIdentity` 来覆盖——覆盖 seam 就是函数参数,在有消费方需要之前不做部署配置管道——省略时回退到 harness 默认值而非抑制归属。没有逐请求 API 允许模型、用户提示词、会话 id、cwd、用户邮箱、API key 所有者或本地机器身份影响这些字段。 @@ -77,7 +77,7 @@ LLM(大语言模型)提供方请求应当标识发出请求的产品。这 **提供方看到流量来自 harness。** 这正是目的,但意味着此前混在通用 SDK 流量中的部署变得可识别。缓解措施:仅发送静态公开产品数据,并允许 fork/白标部署传入自己的 `AppIdentity`。 -**应用 URL 指向一个尚不存在的仓库。** `deepseek-ai/deepseek-harness-sdk` 是计划中的公开主页;在它创建之前,该 URL 是一个悬空承诺。常量上的 `FIXME` 标记阻塞发布,不允许带着未解决的问题出门(见 `docs/development.md` 标记语义)。 +**应用 URL 指向一个尚不存在的仓库。** `deepseek-ai/deepseek-harness-sdk` 是计划中的公开主页;在它创建之前,该 URL 是一个悬空承诺。[#1972](https://github.com/deepseek-harness/deepseek-harness/issues/1972) 负责在发布前创建该仓库或校正最终 URL。 **不同客户端库的头部支持有差异。** 手写适配器直接设置头部;基于 pi-ai 的适配器依赖 pi-ai 继续尊重 `StreamOptions.headers`(最后合并覆盖提供方默认值)。线路级 mock 服务器测试是守卫:如果 pi-ai 升级后不再投递该头部,套件会变红。这对抽象施加了有益的压力:一个无法设置强制头部的提供方适配器不能完整实现 harness 的 LLM 契约。 diff --git a/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.i18n.yaml index 48d3522a70..072823d0dc 100644 --- a/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.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-02-tool-render-intent-union.md -2026-07-02-tool-render-intent-union.md: d82141f519bff66df000f1316093aacd38b8e42b -2026-07-02-tool-render-intent-union.zh.md: e145908e019e71765a475b0ca9d22414e9b42d23 +2026-07-02-tool-render-intent-union.md: 67607b2848305439513503d7e03ad5e2a2e4020a +2026-07-02-tool-render-intent-union.zh.md: 9cbab75ce87f6d313ca4b6ac8dda043733361f0d diff --git a/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md b/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md index d82141f519..67607b2848 100644 --- a/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md +++ b/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md @@ -14,7 +14,7 @@ A tool declares how its calls render in a UI (an editor's tool-call card) throug - Which combinations are *valid* is unwritten: a `terminal` call that also sets `content` means "description above the card"; a generic call that sets `terminal` is meaningless but representable. The type permits nonsense. - There is no way to express the one file-tool affordance an editor most wants — a **diff card** (`{path, oldText, newText}`, which Zed renders as an inline diff / new-file preview). `ToolCallPresentation.content` is the *LLM* `ContentBlock[]` vocabulary (text/image), so a tool literally cannot ask for a diff. -The existing `FIXME(tool-presentation)` in `packages/core/tools/src/index.ts` named the fix: "redesign the type so a tool declares its render INTENT once (e.g. a tagged union over card kinds) rather than a bag of optional fields the bridge stitches together." An earlier rejected collapse-tool-owned-presentation proposal deferred it explicitly: rich rendering "should return later as a tagged render-intent union after there are at least two real tools and two real consumers to validate the vocabulary." That bar is met by multiple producer families plus the TUI and host/client-runtime (Web) consumers. +An earlier rejected collapse-tool-owned-presentation proposal deferred rich rendering until it could "return later as a tagged render-intent union after there are at least two real tools and two real consumers to validate the vocabulary." That bar is met by multiple producer families plus the TUI and host/client-runtime (Web) consumers. ## Decision diff --git a/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md b/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md index e145908e01..9cbab75ce8 100644 --- a/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md @@ -14,7 +14,7 @@ Status: implemented - 哪些组合是*合法的*没有文档说明:一个设置了 `content` 的 `terminal` 调用意味着「卡片上方的描述」;一个设置了 `terminal` 的 generic 调用毫无意义但类型上可表达。类型允许无意义的状态存在。 - 无法表达编辑器最需要的文件工具能力:**diff 卡片**(`{path, oldText, newText}`,Zed 将其渲染为内联 diff / 新文件预览)。`ToolCallPresentation.content` 使用的是 *LLM(大语言模型)* 的 `ContentBlock[]` 词汇(text/image),工具根本无法请求 diff 展示。 -`packages/core/tools/src/index.ts` 中已有的 `FIXME(tool-presentation)` 指出了修复方向:「重新设计类型,让工具一次性声明其渲染意图(例如按卡片种类的带标签联合类型),而非一堆由 bridge 拼接的可选字段。」一个早先被否决的折叠工具自有呈现提案明确推迟了此事:富渲染「应当在至少有两个真实工具和两个真实消费方验证词汇之后,以带标签 render-intent 联合类型的形式回归。」该条件已由多个生产者族,加上 TUI 与宿主/客户端运行时(Web)这些消费方满足。 +一个早先被否决的折叠工具自有呈现提案把富渲染推迟到它能够「在至少有两个真实工具和两个真实消费方验证词汇之后,以带标签 render-intent 联合类型的形式回归」之时。该条件已由多个生产者族,加上 TUI 与宿主/客户端运行时(Web)这些消费方满足。 ## 决策 diff --git a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml index 8a2190b500..26354042d7 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-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-07-05-prompt-variables-and-tool-guidance-ownership.md -2026-07-05-prompt-variables-and-tool-guidance-ownership.md: 94f5fa409e7b539b48750d12576c7a342a30c9ba -2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md: 341b3a89f423c9cc7d2fc56f1ea25a1985680d0d +2026-07-05-prompt-variables-and-tool-guidance-ownership.md: a3b5021daf323971308760bde4f97651db8edbda +2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md: 490d41302ea4f4e14e11e301acbf47170f95cace diff --git a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md index 94f5fa409e..a3b5021daf 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md +++ b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md @@ -10,7 +10,7 @@ The assembled system prompt had four defects, all of one family: facts the harne **The model could not know its own name.** `AgentOptions.model` drives every request, but no prompt text carried it — and nothing COULD carry it: sections in `dsh-system-prompt` were context-global while the model name is per-agent, and `assemble()` took no per-agent input at all. -**Tool guidance was hand-written prose in leaf YAML.** The bash/subagent/todo_write usage guidance lived in the coding-agent and ACP persona strings — two drifting copies (the ACP one was already abridged) — while `dsh-tool-fs` and `dsh-tool-web` owned their guidance as `ctx.systemPrompt.section()` contributions. Loading or dropping a tool plugin meant editing every deployment's persona by hand; both YAMLs carried a `FIXME(config-comments)` apologizing for a symptom of the split, and the old terminal welcome banner hand-enumerated the tool set too. +**Tool guidance was hand-written prose in leaf YAML.** The bash/subagent/todo_write usage guidance lived in the coding-agent and ACP persona strings — two drifting copies (the ACP one was already abridged) — while `dsh-tool-fs` and `dsh-tool-web` owned their guidance as `ctx.systemPrompt.section()` contributions. Loading or dropping a tool plugin meant editing every deployment's persona by hand, and the old terminal welcome banner hand-enumerated the tool set too. **The persona rendered after tool guidance.** The loop string-joined `agent.options.systemPrompt` AFTER the assembled sections, so the model read "Use the read tool…" before "You are a coding agent" — backwards relative to the identity-first convention (Claude Code, Codex) and a second composition path besides the section pipeline. diff --git a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md index 341b3a89f4..490d41302e 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md @@ -10,7 +10,7 @@ Status: implemented **模型无法知道自己的名字。** `AgentOptions.model` 驱动每个请求,但没有任何提示词文本携带它——也不可能携带:`dsh-system-prompt` 中的 section 是上下文全局的,而模型名称是 per-agent 的,`assemble()` 根本不接受任何 per-agent 输入。 -**工具指导是 leaf YAML 中的手写行文。** bash/subagent/todo_write 的使用指导存放在 coding-agent 和 ACP persona 字符串里——两份漂移的副本(ACP 那份已经被删减)——而 `dsh-tool-fs` 和 `dsh-tool-web` 则通过 `ctx.systemPrompt.section()` 贡献各自的指导。加载或卸载一个工具插件意味着手动编辑每个部署的 persona;两份 YAML 都带着一条 `FIXME(config-comments)` 为这种分裂的症状道歉,旧终端欢迎横幅也手动枚举了工具集。 +**工具指导是 leaf YAML 中的手写行文。** bash/subagent/todo_write 的使用指导存放在 coding-agent 和 ACP persona 字符串里——两份漂移的副本(ACP 那份已经被删减)——而 `dsh-tool-fs` 和 `dsh-tool-web` 则通过 `ctx.systemPrompt.section()` 贡献各自的指导。加载或卸载一个工具插件意味着手动编辑每个部署的 persona,旧终端欢迎横幅也手动枚举了工具集。 **Persona 渲染在工具指导之后。** agent loop(智能体循环)将 `agent.options.systemPrompt` 字符串拼接在已组装的 section 之后,于是模型先读到「Use the read tool…」再读到「You are a coding agent」——与 identity-first 约定(Claude Code、Codex)相反,且是 section 流水线之外的第二条组合路径。 diff --git a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml index d326b413bb..2926746ee1 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.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-05-reconstructable-requests.md -2026-07-05-reconstructable-requests.md: 2f559a3052b9fb84f788975a64799e4f020b0d3e -2026-07-05-reconstructable-requests.zh.md: 26abdc024a166856e51ebf09f086c7868fc8236d +2026-07-05-reconstructable-requests.md: ebca9b99cad791159302da9c2bbce9f4df147aab +2026-07-05-reconstructable-requests.zh.md: 91ef3fd04502f2c2f092da60a8bd909cf7fa25df diff --git a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md index 2f559a3052..ebca9b99ca 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md +++ b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md @@ -53,4 +53,3 @@ Like MiniCode, the conversation advances append-only and resets only when model- - Tool-result trimming (planned) needs no new mechanism: a logged single-entry surface replace (`start === end`) carrying a trimmed `tool/result` under the same `callId` — compaction-family, replay-correct, cache-bust batched by the same pressure logic. - Session logs grow one `request/header` snapshot per loop instance plus snapshots on real changes. This is larger than a delta codec but small beside chunk-heavy logs and retains one replay representation. `SESSION_FORMAT_VERSION` stays `0`; legacy delta events are rejected rather than migrated. - Snapshot expected outputs changed once (every transcript gains its header events); the fs-writing fixtures are stored in the normalized authored form with cwd-relative tool arguments, because replay only round-trips cwd-independent argument paths. -- FIXME(call-config-shape): revisit `LlmCallConfig`'s exact field set — which fields are genuinely epoch-level for cache purposes (`model` certainly; the sampling scalars sit there out of caution), and where provider-specific extras (reasoning options, extra body params) belong when an adapter needs them. diff --git a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md index 26abdc024a..91ef3fd045 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md @@ -53,4 +53,3 @@ Status: implemented - 工具结果裁剪(计划中)无需新机制:一个已记录的单条目 surface replace(`start === end`),携带同一 `callId` 下裁剪后的 `tool/result`——属压缩家族,回放正确,缓存击穿由相同的压力逻辑批量处理。 - 会话日志每个循环实例增长一个 `request/header` 快照,并在真正变更时增加快照。它比 delta 编解码器更大,但相对分片密集型日志仍然很小,并只保留一种回放表示。`SESSION_FORMAT_VERSION` 保持 `0`;旧的 delta 事件被拒绝而非迁移。 - 快照预期输出变更一次(每个 transcript(文本记录)增加其 header 事件);写入文件系统的 fixture(测试前置数据)以规范化的撰写形式存储,工具参数使用 cwd 相对路径,因为回放只对 cwd 无关的参数路径做往返。 -- FIXME(call-config-shape):重新审视 `LlmCallConfig` 的确切字段集——哪些字段对缓存而言真正属于 epoch 级别(`model` 毫无疑问;采样标量出于谨慎放在那里),以及当适配器需要时,提供方特定的额外项(推理(reasoning)选项、额外 body 参数)应归属何处。 diff --git a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml index c981d84400..f071577bdd 100644 --- a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.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-06-18-compaction-capability-seam.md -2026-06-18-compaction-capability-seam.md: 26e6e2468c7bea661d85c8fb994adf8b109105ee -2026-06-18-compaction-capability-seam.zh.md: 8f9cd1f6bc31e648a5b923e816cad75ebc1a0bd8 +2026-06-18-compaction-capability-seam.md: efb37482270a7952f6af6596f9afd12f17048bcc +2026-06-18-compaction-capability-seam.zh.md: 214832923c4e24835e7b25a5bbf2b1bcd62dff42 diff --git a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md index 26e6e2468c..efb3748227 100644 --- a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md +++ b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md @@ -131,4 +131,4 @@ The lifecycle boundary makes crash state unambiguous: - **Loop:** Tests pin pre-step after the preceding `step/end` and before the next `step/start`, actual `agent/request` routing, closed failed steps, fresh retry numbering, and complete thrown/in-band overflow → compaction → reconstructed retry composition. - **Manual:** Maintenance serialization, marker ordering, injection retention, live/stale orphan classification, cancellation, close/flush failures, command mapping, and the queued TUI journey are pinned without a model key. - **With-key e2e:** A real model and bash session with lowered limits triggers compaction, records a complete `compact/start…end` pair, shrinks the surface, and finishes the task. -- **Snapshot gap:** Runaway-turn compaction cannot yet replay because the summarization call records no `assistant/chunk` events or `sessionId`; interleaved summarization-call replay remains follow-up work. +- **Snapshot gap:** The summarization call is session-associated and logs `compact/summary`, but ordinary transcript replay does not derive its auxiliary response. [#1971](https://github.com/deepseek-harness/deepseek-harness/issues/1971) tracks a keyless assembled scenario with an explicit replay override. diff --git a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md index 8f9cd1f6bc..214832923c 100644 --- a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md +++ b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md @@ -131,4 +131,4 @@ compact/end → log-only. Releases the lock (carries `error` on a recoverab - **循环测试:** 测试固定 pre-step 发生在前一个 `step/end` 之后、下一个 `step/start` 之前,使用实际 `agent/request` 路由,关闭失败步骤,分配新的重试编号,并覆盖完整的抛出/带内溢出 → 压缩 → 重建重试组合。 - **手动测试:** 无需模型密钥即可固定 maintenance 串行化、标记顺序、注入保留、活动/陈旧未匹配标记分类、取消、闭合/flush 失败、命令映射以及排队 TUI 流程。 - **带密钥 e2e:** 真实模型和 bash 会话在降低的限制下触发压缩,记录完整的 `compact/start…end` 对,缩小 surface,并完成任务。 -- **快照缺口:** 失控轮次压缩尚无法回放,因为摘要调用未记录 `assistant/chunk` 事件或 `sessionId`;交错摘要调用的回放仍是后续工作。 +- **快照缺口:** 摘要调用与会话关联并记录 `compact/summary`,但普通 transcript(文本记录)回放不会派生其辅助响应。[#1971](https://github.com/deepseek-harness/deepseek-harness/issues/1971) 跟踪一个带显式回放 override 的无密钥组装场景。 diff --git a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml index eea7ced3d2..40e24e9ac2 100644 --- a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.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-08-02-pwsh-tool-bash-parity.md -2026-08-02-pwsh-tool-bash-parity.md: 945d2d5243162fe8e7fb3f76cbc3bcf0b5c2fdee -2026-08-02-pwsh-tool-bash-parity.zh.md: f537e313a0c895927c6e2319b11b98619a70d461 +2026-08-02-pwsh-tool-bash-parity.md: e35a903892d5d50a0d3ca12b23daa53f26d6aade +2026-08-02-pwsh-tool-bash-parity.zh.md: d7a68f0cab5281b25c3321e7ffff61c358b21a0a diff --git a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md index 945d2d5243..e35a903892 100644 --- a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md +++ b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md @@ -14,7 +14,7 @@ The first Windows-native foundation shipped `dsh-tool-pwsh` as a deliberately mi - **Rendering adopts the bash story verbatim**: stdout, a marked `[stderr]` section, truncation notices with spill paths, `(no output)` for an empty body, and exit markers only for non-zero exits — a clean exit produces no marker. The description and the `tool:pwsh` prompt section state this precisely ("Non-zero exits are reported as `[exit code: N]` markers"), deliberately not copying the bash prompt's "every result" phrasing, which its own renderer contradicts. - **`run_in_background` is wired through the generic task runtime** exactly like the bash tool: preflight, owner registration, `task_output`/`task_kill` control, and the same outcome mapping. `pwsh-local`'s already-mirrored `start()` handle backs it. -- **The `DSH_*` environment is shared, not duplicated**: `BashEnvRegistry` moved out of `dsh-tool-bash` into a new tool-independent `@deepseek-ai/dsh-bash-env` package (`ctx.bashEnv` + built-ins + the session-persistence contributor), and both shell tools inject it. Contributors apply to pwsh calls exactly as they do to bash calls, resolving the bash tool's `FIXME(bash-env-ownership)`. +- **The `DSH_*` environment is shared, not duplicated**: `BashEnvRegistry` moved out of `dsh-tool-bash` into a new tool-independent `@deepseek-ai/dsh-bash-env` package (`ctx.bashEnv` + built-ins + the session-persistence contributor), and both shell tools inject it. Contributors apply to pwsh calls exactly as they do to bash calls; shared environment ownership therefore sits outside either model-facing shell tool. - **Windows reality is pinned where bash has no analog**: every command runs under a UTF-8 output preamble so the Windows PowerShell 5.1 fallback cannot garble non-ASCII output through the UTF-8-decoding collector, and the prompts teach that Windows forced termination settles as exit 1 without a signal marker. - **Out of scope, unchanged**: sandbox escalation (waits for a Windows-confining executor) and persistent PTY shells (backends are Linux/macOS-only; ConPTY is roadmap work). The pwsh-specific terminal card with an exit pill shipped separately in the [pwsh UI presentation matches bash](2026-08-05-pwsh-ui-bash-parity.md) decision. diff --git a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md index f537e313a0..d7a68f0cab 100644 --- a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md +++ b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md @@ -14,7 +14,7 @@ Status: implemented - **渲染完全采用 bash 故事**:stdout、带标记的 `[stderr]` 段、带 spill 路径的截断通知、空体渲染 `(no output)`、退出 marker 仅限非零退出——干净退出不产生 marker。描述与 `tool:pwsh` prompt section 精确陈述这一点("Non-zero exits are reported as `[exit code: N]` markers"),刻意不复制 bash prompt 中与其自身渲染矛盾的 "every result" 措辞。 - **`run_in_background` 经通用任务运行时接线**,与 bash 工具完全一致:预检、owner 注册、`task_output`/`task_kill` 控制与相同的结果映射。其背后是 `pwsh-local` 早已镜像好的 `start()` 句柄。 -- **`DSH_*` 环境共享而非复制**:`BashEnvRegistry` 从 `dsh-tool-bash` 迁入新的工具无关包 `@deepseek-ai/dsh-bash-env`(`ctx.bashEnv` + 内置事实 + session-persistence contributor),两个 shell 工具都注入它。contributor 对 pwsh 调用与 bash 调用一视同仁,并消化了 bash 工具的 `FIXME(bash-env-ownership)`。 +- **`DSH_*` 环境共享而非复制**:`BashEnvRegistry` 从 `dsh-tool-bash` 迁入新的工具无关包 `@deepseek-ai/dsh-bash-env`(`ctx.bashEnv` + 内置事实 + session-persistence contributor),两个 shell 工具都注入它。contributor 对 pwsh 调用与 bash 调用一视同仁;因此,共享环境的所有权不属于任何一个面向模型的 shell 工具。 - **Windows 现实在 bash 无对应处钉死**:每条命令都在 UTF-8 输出 preamble 下运行,使 Windows PowerShell 5.1 兜底无法经 UTF-8 解码的 collector 破坏非 ASCII 输出;prompt 教授 Windows 强制终止以无 signal 的 exit 1 结算。 - **范围外,不变**:sandbox 升级(等待 Windows-confining 执行器)与持久 PTY shell(后端仅限 Linux/macOS;ConPTY 属路线图)。带退出 pill 的 pwsh 专属 terminal 卡已随 [pwsh UI 呈现与 bash 对齐](2026-08-05-pwsh-ui-bash-parity.md) 决策另行交付。 diff --git a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml index 1a179748f9..79ea52067d 100644 --- a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.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-07-31-installer-adopts-existing-checkout.md -2026-07-31-installer-adopts-existing-checkout.md: de3cd052f94a0d5256c7687e9a1a38ee69fd2caf -2026-07-31-installer-adopts-existing-checkout.zh.md: 2e8be804b4af6151e77e36f8b109616aab3a18e9 +2026-07-31-installer-adopts-existing-checkout.md: ff02fe837f2ad4deb3fb852f610f3cd3ff9a23d7 +2026-07-31-installer-adopts-existing-checkout.zh.md: 28816c80764acc0d4a2fcd13b3b8a38807021fd6 diff --git a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md index de3cd052f9..ff02fe837f 100644 --- a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md +++ b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md @@ -46,6 +46,6 @@ A container adopting an outside clone is also no longer self-contained: deleting ## Testing -`scripts/install.sh` has no automated test, and this change does not add one: the user directed that `install.spec.ts` be left out of scope. That is a known gap on a shipped user-facing path, and the `/var` resolution defect above is exactly the class of bug a test would have caught first. The standing [`FIXME(install-ts)`](../../../../scripts/install.sh) asking for this workflow to move into a tested TypeScript entrypoint is correspondingly more pressing. +`scripts/install.sh` now has a real-shell PTY regression suite in `apps/cli/tests/install-script.spec.ts`, covering adoption and curl-style paths with stubbed dependencies. The installer's longer-term deletion in favor of pnpm/npx is tracked in [#1890](https://github.com/deepseek-harness/deepseek-harness/issues/1890). Verification was manual, through a throwaway harness driving the real script with a stubbed `pnpm`: adopting a standalone clone; adopting from a linked worktree into its existing container; an explicit `DSH_SOURCE` still opting back into cloning; a dirty tree adopting silently with no prompt or warning while its uncommitted file stays behind; a non-git checkout failing with guidance; and a `curl`-style clone install asserting the built layout, which is the regression that caught the unresolved-`REPO_ROOT` defect. The interactive path was exercised under tmux from a dirty checkout, confirming the run reaches the launcher with no adoption prompt and ends with `dsh` running from the new staging worktree while the original checkout keeps its branch and its uncommitted file. diff --git a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md index 2e8be804b4..28816c8076 100644 --- a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md +++ b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md @@ -46,6 +46,6 @@ Status: implemented ## Testing -`scripts/install.sh`没有自动化测试,本次变更也未添加:用户明确要求把`install.spec.ts`排除在范围之外。这是一条已交付的、面向用户的安装路径上的已知缺口,而上文那个`/var`解析缺陷,恰恰属于测试本应最先捕获的那类 bug。相应地,要求把这套流程迁移到有测试覆盖的 TypeScript 入口的既有[`FIXME(install-ts)`](../../../../scripts/install.sh)也变得更为紧迫。 +`scripts/install.sh` 现有一套位于 `apps/cli/tests/install-script.spec.ts` 的真实 shell PTY 回归测试,使用 stub 依赖覆盖接管路径和 curl 风格路径。[#1890](https://github.com/deepseek-harness/deepseek-harness/issues/1890) 跟踪安装器的长期删除工作,届时将改用 pnpm/npx。 验证是手工完成的,通过一个一次性测试装置以打桩的`pnpm`驱动真实脚本:接管独立克隆;从 linked worktree 接管进其已有容器;显式`DSH_SOURCE`仍回到克隆路径;工作树不干净时静默接管、既不提示也不警告,且其未提交文件留在原处;非 git 检出失败并给出指引;以及`curl`式克隆安装断言所构建的布局——正是这项回归测试捕获了`REPO_ROOT`未解析的缺陷。交互路径在 tmux 下从一个不干净的检出走通,确认整个过程不出现接管提示即可到达启动器,最终`dsh`从新的 staging worktree 运行,而原检出保持其分支不变、未提交文件仍在。 diff --git a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml index fe97a7b717..b7d396e007 100644 --- a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.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/testing/2026-06-19-acp-snapshot-tests.md -2026-06-19-acp-snapshot-tests.md: e118ada58230fe31fbb2a6bffb83e5612757ab1f -2026-06-19-acp-snapshot-tests.zh.md: e292dbf3bf3c4c5198dc77d122bb6b8c36014ebf +2026-06-19-acp-snapshot-tests.md: 39d3b7a3f4699ea96262f43c63a7d60574ba064f +2026-06-19-acp-snapshot-tests.zh.md: 7dd3a3fa83682c35945314c7cd9531ca72bbb1fb diff --git a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md index e118ada582..39d3b7a3f4 100644 --- a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md +++ b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md @@ -80,6 +80,6 @@ Tool determinism comes from a generated cwd, scrubbed environment, fresh non-log ## Consequences -The tier adds reviewed per-scenario input, session, stdout, optional override, and optional workspace fixtures, plus one file for each distinct pinned prompt and tool-schema sequence. Workspace seeds are copied into the generated cwd for both record and replay. In return the tier provides deterministic keyless coverage through the real Loader and tool composition. Most retained scenarios exercise the assembled backend rather than ACP; the [automation-only ACP decision](../simplification/2026-07-23-acp-automation-only-protocol.md#snapshot-boundary) keeps that corpus here and defers any move to a transport-neutral headless suite as an independent testing change (the suite-level FIXME marks it). +The tier adds reviewed per-scenario input, session, stdout, optional override, and optional workspace fixtures, plus one file for each distinct pinned prompt and tool-schema sequence. Workspace seeds are copied into the generated cwd for both record and replay. In return the tier provides deterministic keyless coverage through the real Loader and tool composition. Most retained scenarios exercise the assembled backend rather than ACP; the [automation-only ACP decision](../simplification/2026-07-23-acp-automation-only-protocol.md#snapshot-boundary) keeps that corpus here, while [#1970](https://github.com/deepseek-harness/deepseek-harness/issues/1970) tracks moving it to a transport-neutral headless suite without losing coverage. This Agent Note relates to but does not supersede the [proposed determinism Agent Note](../../proposed/testing/2026-06-11-deterministic-and-stress-testing.md): that proposal's "universal replay fixture" re-derives session *message history* after every test (an internal-consistency invariant), whereas these snapshots pin assembled behavior plus the external automation output. They are complementary until the backend corpus moves off ACP. diff --git a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md index e292dbf3bf..7dd3a3fa83 100644 --- a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md +++ b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md @@ -80,6 +80,6 @@ Status: implemented ## 后果 -该测试层为每个场景增加经过评审的输入、会话、stdout、可选 override 和可选 workspace fixture,并为每个不同的已固定提示词序列、每个不同的已固定工具 schema 序列各增加一个文件。记录与回放都会把 workspace seed 复制到生成的 cwd。作为回报,该层通过真实 Loader 和工具组合提供确定性的无密钥覆盖。保留下来的大多数场景测试的是组装后的后端而非 ACP;[仅面向自动化的 ACP 决策](../simplification/2026-07-23-acp-automation-only-protocol.md#snapshot-boundary)将该语料保留在此处,并把向传输无关 headless 套件的任何迁移推迟为一项独立的测试变更(套件级 FIXME 标记了这一点)。 +该测试层为每个场景增加经过评审的输入、会话、stdout、可选 override 和可选 workspace fixture,并为每个不同的已固定提示词序列、每个不同的已固定工具 schema 序列各增加一个文件。记录与回放都会把 workspace seed 复制到生成的 cwd。作为回报,该层通过真实 Loader 和工具组合提供确定性的无密钥覆盖。保留下来的大多数场景测试的是组装后的后端而非 ACP;[仅面向自动化的 ACP 决策](../simplification/2026-07-23-acp-automation-only-protocol.md#snapshot-boundary)将该语料保留在此处,而 [#1970](https://github.com/deepseek-harness/deepseek-harness/issues/1970) 跟踪在不损失覆盖的情况下将其迁移到传输无关的 headless 套件。 本 Agent Note 与[拟议的确定性 Agent Note](../../proposed/testing/2026-06-11-deterministic-and-stress-testing.md)相关,但不取代它:该提案的“通用回放 fixture”在每次测试后重新派生会话*消息历史*(内部一致性不变量),而这些快照固定组装后的行为与外部自动化输出。在后端语料迁出 ACP 之前,两者相互补充。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 821948f873..66b3a5399e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,8 @@ env: jobs: - # FIXME: Re-enable the three hosted serial reference jobs before cutting a release. + # https://github.com/deepseek-harness/deepseek-harness/issues/1967 tracks + # restoring the three hosted serial reference jobs before release. # The self-hosted standby remains active on every master push. # Three enterprise jobs isolate coverage, static analysis, and the diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index 461d1ef4fc..e79d5dac2f 100644 --- a/docs/core-data-structures/core.i18n.yaml +++ b/docs/core-data-structures/core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/core.md -core.md: f7cf288715a3aec2f7037f12fc983e3172a77cef -core.zh.md: c17fd1335503c95e7f7f6f96cc286f567a8384e6 +core.md: dbd584f10b3daf873bc14210472efb6cd315717e +core.zh.md: 1fe1616a0c96abb4e8b91417cc4eae292416e42a diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index f7cf288715..dbd584f10b 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -530,8 +530,6 @@ The loop builds each request from logged state. `EpochHeader` records call confi On the wire, a loop-built request reads the `system` slot (the rendered prompt assembly) followed by the derived history — the boundary snapshot, whose tail is the newest `user/message` on a turn's first step and the previous step's tool results on later steps. The dev invariant recomputes exactly this equation against every loop-built request. -FIXME(call-config-shape): revisit which remaining fields are genuinely epoch-level for cache purposes (`model` and the model-owned reasoning effort are explicit; the sampling scalars sit here out of caution). - ```ts type-equiv /** * Provider, model, reasoning effort, and sampling scalars of one conversation's diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index c17fd13355..1fe1616a0c 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -536,8 +536,6 @@ interface ToolSchema { 在协议格式上,循环构建的请求先读取 `system` 槽位(渲染后的提示词组装),再读取派生历史——边界快照,其尾部在轮次首步是最新的 `user/message`,在后续步骤是上一步的工具结果。开发不变式针对每个循环构建的请求精确重算此等式。 -FIXME(call-config-shape):重新审视其余哪些字段出于缓存目的确实属于 epoch 层级(`model` 和模型持有的推理强度已明确属于;采样标量目前出于谨慎保留在此)。 - ```ts type-equiv /** * Provider, model, reasoning effort, and sampling scalars of one conversation's diff --git a/docs/glossary.i18n.yaml b/docs/glossary.i18n.yaml index 5c6f7d4630..2dfe08c2fd 100644 --- a/docs/glossary.i18n.yaml +++ b/docs/glossary.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/glossary.md -glossary.md: 0270a2d0dba558483e8e458a932a27b0151f2c93 -glossary.zh.md: c3584731cc08b23cf7f47c09620a77b7bff65689 +glossary.md: 16409517bff623a80d6e1e00888d95f63b42f780 +glossary.zh.md: fe3138ac81f2681d7fc69cc2aede96edeb7ec176 diff --git a/docs/glossary.md b/docs/glossary.md index 0270a2d0db..16409517bf 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -4,8 +4,6 @@ English | [中文](glossary.zh.md) Domain vocabulary for the DeepSeek Harness SDK uses one canonical term per concept. Terms link to their entries with standard Markdown anchors; implementation detail stays in package READMEs and Agent Notes. -FIXME(glossary-completeness): Expand this glossary before the first release so it covers the SDK's other core and capability subsystems, not only agent scope. - ## agent-scope - **scope** — the unit of per-agent registration: a contribution (tool, prompt section, variable, restriction, listener) is either *global* (visible to every agent) or *scoped* (owned by exactly one [scope key](#scope-key)). Two levels, flat: scoped registrations do not inherit down to subagents; subtree behavior is expressed with [lineage](#lineage) data, never scope structure. diff --git a/docs/glossary.zh.md b/docs/glossary.zh.md index c3584731cc..fe3138ac81 100644 --- a/docs/glossary.zh.md +++ b/docs/glossary.zh.md @@ -4,8 +4,6 @@ DeepSeek Harness SDK 的领域词汇为每个概念规定一个规范术语。各术语通过标准 Markdown 锚点链接到相应条目;实现细节留在各包的 README 与 Agent Note 中。 -FIXME(glossary-completeness): 首次发布前扩充本术语表,使其覆盖 SDK 的其他核心与能力子系统,而非仅限于 agent scope。 - ## agent-scope - **scope**:按 agent(智能体)划分的注册单位。一项贡献(工具、提示词片段、变量、限制、监听器)要么是*全局的*(对所有 agent 可见),要么是*带作用域的*(归属于恰好一个 [scope key](#scope-key))。只有两层,采用扁平结构:带作用域的注册不会向下继承给 subagent;子树行为通过 [lineage](#lineage) 数据表达,从不通过 scope 结构。 diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index bf4f7ac972..b2fd25eb4e 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -90,8 +90,8 @@ async function prepareFsSearchWorkspace(cwd: string): Promise { } } -// FIXME: Migrate backend-oriented scenarios to the headless stream-json suite; -// this ACP suite should eventually retain only automation-protocol contracts. +// https://github.com/deepseek-harness/deepseek-harness/issues/1970 tracks moving +// backend/product scenarios to headless while retaining ACP protocol contracts here. function fixtureRecords(name: string): unknown[] { return readFileSync(join(SNAPSHOTS_DIR, name, 'session.jsonl'), 'utf8') diff --git a/examples/headless-agent/tests/compaction.e2e.ts b/examples/headless-agent/tests/compaction.e2e.ts index 07f239a73e..fdae11d2c7 100644 --- a/examples/headless-agent/tests/compaction.e2e.ts +++ b/examples/headless-agent/tests/compaction.e2e.ts @@ -10,9 +10,9 @@ 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. + * A keyless assembled snapshot with an explicit summarization replay override + * is tracked in https://github.com/deepseek-harness/deepseek-harness/issues/1971. */ -// FIXME(compaction-snapshot): this is the only full compaction coverage because -// replay cannot serve the summarizer's unlogged model call. let workdir: string | undefined let ctx: Context | undefined diff --git a/packages/client/runtime/src/client/slots.ts b/packages/client/runtime/src/client/slots.ts index 3698f62aab..9da8755b2d 100644 --- a/packages/client/runtime/src/client/slots.ts +++ b/packages/client/runtime/src/client/slots.ts @@ -19,7 +19,7 @@ import type { Context } from 'cordis' import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots' import type { LocaleFace, OwnerOf, SlotEntryDef, SlotMap, SlotRenderer, SlotRendererHost, - SlotScope, SlotSpec, StoreDecl, StoredEntry, StoreInstanceLike, + SlotScope, SlotSpec, StoreDecl, StoreFactory, StoredEntry, StoreInstanceLike, } from '@deepseek-ai/dsh-client-ui-slots' declare module '@deepseek-ai/dsh-client-ui-slots' { @@ -35,16 +35,11 @@ export interface RootOwnerProps { children?: never } /** Instance key for root-scoped store records (session records key by session id, so the literal cannot collide). */ const ROOT_INSTANCE_KEY = 'root' -// FIXME(slot-parity): the engine's arbitrated persist extensions — create() -// takes the scope key (per-session localStorage suffix) and instances expose -// clearPersisted() — are not yet on ui-slots' StoreHandle/StoreInstanceLike; -// these local structural faces bridge until fw-slots lifts them. +/** Canonical type-erased store handle used by the runtime lifecycle map. */ +type EngineStoreHandle = Exclude -/** Store handle face as the engine actually ships it (scope-key-aware create). */ -interface EngineStoreHandle { create(scopeKey?: string): EngineStoreInstance } - -/** Engine instance face: the host-contract shape plus persisted-state cleanup. */ -interface EngineStoreInstance extends StoreInstanceLike { clearPersisted(): void } +/** Canonical engine instance derived from the handle's create contract. */ +type EngineStoreInstance = ReturnType /** Store axis record: one per live handle, dropped when the last holding entry unloads. */ interface StoreAxisRecord { diff --git a/packages/cordis/tool-cordis/README.i18n.yaml b/packages/cordis/tool-cordis/README.i18n.yaml index 0a55bfc7f6..fd2b5443ef 100644 --- a/packages/cordis/tool-cordis/README.i18n.yaml +++ b/packages/cordis/tool-cordis/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/tool-cordis/README.md -README.md: eda135d93e2912bbb4e111af40d176409b383b5b -README.zh.md: 773d4100f1be6c54f491838b65205b85ec61cdbc +README.md: 9986310160c2b56126155a4c3ef84d66018d31c2 +README.zh.md: d955306e1e5d4154f58c771704782ece44a15c99 diff --git a/packages/cordis/tool-cordis/README.md b/packages/cordis/tool-cordis/README.md index eda135d93e..9986310160 100644 --- a/packages/cordis/tool-cordis/README.md +++ b/packages/cordis/tool-cordis/README.md @@ -85,5 +85,5 @@ Mounting or unmounting a prompt or tool contribution changes later request prefi ## Known Limitations and Deferred Work - **The sandbox is containment for honest code, not a security boundary** — host-realm helpers on the sandbox global are reachable, so mount code can reach Node; load this plugin as deliberately as you would grant a bash tool (see § Trust stance). -- **The `ctx` façade exposes no `effect()`** — mount code cannot register a bespoke disposer; `on`/`provide`/`tools.register` cover every mount seen so far, and a guarded `effect` waits on a real need (`FIXME(sandbox-effect)`). +- **The `ctx` façade exposes no `effect()`** — mount code cannot register a bespoke disposer; `on`/`provide`/`tools.register` are the supported cleanup paths. - **`vmTimeoutMs` bounds only synchronous evaluation** — an async mount body escapes it; there is no async budget on mount code. diff --git a/packages/cordis/tool-cordis/README.zh.md b/packages/cordis/tool-cordis/README.zh.md index 773d4100f1..d955306e1e 100644 --- a/packages/cordis/tool-cordis/README.zh.md +++ b/packages/cordis/tool-cordis/README.zh.md @@ -85,5 +85,5 @@ Namespace 插件:命名导出 `name`/`inject`/`Config`/`apply`,无默 ## 已知限制与暂缓事项 - **沙箱只用于约束诚实代码,并非安全边界**:可以访问沙箱全局变量上的 host realm helper,因此挂载代码可以触达 Node;加载该插件时,应当像授予 bash 工具一样慎重(见 § 信任立场)。 -- **`ctx` façade 不公开 `effect()`**:挂载代码无法注册定制 disposer;`on`/`provide`/`tools.register` 已覆盖目前出现的每项挂载,受保护的 `effect` 会等待真实需求(`FIXME(sandbox-effect)`)。 +- **`ctx` façade 不公开 `effect()`**:挂载代码无法注册定制 disposer;`on`/`provide`/`tools.register` 是受支持的清理路径。 - **`vmTimeoutMs` 只限制同步求值**:async 挂载主体可逃出该边界;挂载代码没有 async 预算。 diff --git a/packages/cordis/tool-cordis/src/guard.ts b/packages/cordis/tool-cordis/src/guard.ts index 595dc462e0..22d85936f4 100644 --- a/packages/cordis/tool-cordis/src/guard.ts +++ b/packages/cordis/tool-cordis/src/guard.ts @@ -749,7 +749,6 @@ export function isPlugin(value: unknown): value is Plugin { * @param plugin - the plugin the mount code returned. * @returns an equivalent plugin whose `apply` sees the sandbox context façade. */ -// FIXME(sandbox-effect): expose guarded custom effects when a mount needs bespoke cleanup. export function guardedPlugin(plugin: Plugin): Plugin { if (typeof plugin === 'function') { const functionPlugin = plugin as (ctx: Context, config?: unknown) => unknown diff --git a/packages/hooks/hooks-claude/tests/bridge.spec.ts b/packages/hooks/hooks-claude/tests/bridge.spec.ts index 1da2771257..244abae423 100644 --- a/packages/hooks/hooks-claude/tests/bridge.spec.ts +++ b/packages/hooks/hooks-claude/tests/bridge.spec.ts @@ -225,7 +225,7 @@ describe('hooks-claude bridge — PostToolUse', () => { expect(ctxMsg?.type === 'user/message' && ctxMsg.data.content.some(b => b.type === 'text' && b.text.includes('tool was slow'))).toBe(true) }) - it('a PreToolUse permissionDecision:ask degrades to ask (the tool is gated, not run)', async () => { + it('a PreToolUse permissionDecision:ask fails closed without an approval service', async () => { const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-')) dirs.push(dir) const s = join(dir, 'ask.sh') @@ -241,7 +241,7 @@ describe('hooks-claude bridge — PostToolUse', () => { agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) - // `ask` degrades to deny today (FIXME permissions): the tool does not run and the result is isError. + // No approval service is mounted, so `ask` fails closed: the tool does not run and the result is isError. expect(ran).toBe(false) const result = events(agent).find(e => e.type === 'tool/result') expect(result?.type === 'tool/result' && result.data.message.content[0].isError).toBe(true) diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml index 0ad49faa17..5225ba9d8c 100644 --- a/packages/llm/llm/README.i18n.yaml +++ b/packages/llm/llm/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm/README.md -README.md: d15b2c996d6d47371a3d6c5542eb5c253029dbae -README.zh.md: d965f15298f09ff9c2a953a69346c4e83136934b +README.md: 956bfa112d6fe50c35359cebdf3710064da8c130 +README.zh.md: 42f2da18089e7dcfc9acb95076ab8786c798444b diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index d15b2c996d..956bfa112d 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -97,5 +97,5 @@ Pass-through; the registry preserves the assembled request prefix, while the sel - **`GenerateOptions` sampling is `temperature`/`maxTokens`/`stop` only** — no `tool_choice`, `top_p`, or penalty fields; the vocabulary grows when a producer lands ([dropped inert knobs](../../../.agents/notes/archived/simplification/2026-07-04-drop-inert-request-knobs.md)). - **Producer-gated variants stay out until produced** — `prefill`, per-tool `strict`, block `cache` hints, and the `agent` message-source variant were pruned as producerless ([Agent Note](../../../.agents/notes/archived/simplification/2026-07-04-prune-producerless-vocabulary-variants.md)). - **`BlockAssembler` handles core block kinds only** — a plugin-added block type whose stream is never closed by `block-end` makes `blocks()` throw. -- **`APP_IDENTITY.url` names a repository that does not exist yet** — `FIXME`: creating the public `deepseek-ai/deepseek-harness-sdk` repo gates the first release. +- **`APP_IDENTITY.url` names a repository that does not exist yet** — [#1972](https://github.com/deepseek-harness/deepseek-harness/issues/1972) tracks making the public home reachable before release. - **`GenerateOptions.sessionId` is a locally-declared brand** — importing dsh-session's `SessionId` would cycle; a future ids-owning package would dissolve the workaround. diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md index d965f15298..42f2da1808 100644 --- a/packages/llm/llm/README.zh.md +++ b/packages/llm/llm/README.zh.md @@ -97,5 +97,5 @@ - **`GenerateOptions` 采样只包含 `temperature`/`maxTokens`/`stop`**:没有 `tool_choice`、`top_p` 或 penalty 字段;有产生方落地时词汇才会增长(见 [已删除惰性旋钮](../../../.agents/notes/archived/simplification/2026-07-04-drop-inert-request-knobs.md))。 - **受产生方约束的变体在实际产生前不会加入**:`prefill`、每工具 `strict`、块 `cache` 提示与 `agent` 消息源变体因没有产生方而被剪除(见 [Agent Note](../../../.agents/notes/archived/simplification/2026-07-04-prune-producerless-vocabulary-variants.md))。 - **`BlockAssembler` 只处理核心块类型**:如果插件添加块类型的流从未由 `block-end` 关闭,`blocks()` 会抛出异常。 -- **`APP_IDENTITY.url` 指向一个尚不存在的仓库**:`FIXME`:创建公开 `deepseek-ai/deepseek-harness-sdk` 仓库是首次发布的前置条件。 +- **`APP_IDENTITY.url` 指向一个尚不存在的仓库**:[#1972](https://github.com/deepseek-harness/deepseek-harness/issues/1972) 跟踪在首次发布前让该公开主页可访问。 - **`GenerateOptions.sessionId` 是本地声明的品牌类型**:导入 dsh-session 的 `SessionId` 会产生循环;未来拥有 id 的包可以消除该权宜之计。 diff --git a/packages/llm/llm/src/attribution.ts b/packages/llm/llm/src/attribution.ts index cdaea4b96b..b9375b6ef9 100644 --- a/packages/llm/llm/src/attribution.ts +++ b/packages/llm/llm/src/attribution.ts @@ -40,8 +40,8 @@ export interface AppIdentity { export const APP_IDENTITY: AppIdentity = { product: 'deepseek-harness', version, - // FIXME: create the public deepseek-ai/deepseek-harness-sdk repository this - // URL promises before the first release ships attribution pointing at it. + // The public-home release blocker is tracked in + // https://github.com/deepseek-harness/deepseek-harness/issues/1972. url: 'https://github.com/deepseek-ai/deepseek-harness-sdk', } diff --git a/packages/sdk/telemetry/README.i18n.yaml b/packages/sdk/telemetry/README.i18n.yaml index 9baaa2ebff..987dc8197c 100644 --- a/packages/sdk/telemetry/README.i18n.yaml +++ b/packages/sdk/telemetry/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/sdk/telemetry/README.md -README.md: 1d33915f36e0af10eedac5f9ab34f2534268a327 -README.zh.md: af54cc2c78cb360d4305eeaf584c8330a0b7efa5 +README.md: c9f66a2415c91b75105b0ed025470da234b2523d +README.zh.md: bfb154e4c7c017292b5479e50ff376cbb9470682 diff --git a/packages/sdk/telemetry/README.md b/packages/sdk/telemetry/README.md index 1d33915f36..c9f66a2415 100644 --- a/packages/sdk/telemetry/README.md +++ b/packages/sdk/telemetry/README.md @@ -14,7 +14,7 @@ Launcher-side telemetry primitives for the dsh-sdk toolchain. This is a plain li Consent is carried by the telemetry entry in `cordis.yml`, so disabling telemetry is disabling that entry. Telemetry reports by default and is off only when a present telemetry entry is explicitly `disabled`: a missing `cordis.yml` (first `create`), an enabled entry, or a `cordis.yml` with no telemetry entry all report. `DO_NOT_TRACK`/CI always deny. The no-config and absent-entry defaults are configurable on `ConsentResolver`. -The collection endpoint is a fixed constant (`DSH_TELEMETRY_ENDPOINT`); its `.invalid` placeholder must be replaced with the real endpoint before release. +The collection endpoint is a fixed constant (`DSH_TELEMETRY_ENDPOINT`); [#1973](https://github.com/deepseek-harness/deepseek-harness/issues/1973) tracks deploying the service and replacing its fail-safe `.invalid` placeholder before release. ## Model Experience @@ -26,5 +26,5 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **Placeholder endpoint** — `DSH_TELEMETRY_ENDPOINT` points at `.invalid` until the real endpoint is set. +- **Placeholder endpoint** — `DSH_TELEMETRY_ENDPOINT` points at `.invalid` until the service tracked in [#1973](https://github.com/deepseek-harness/deepseek-harness/issues/1973) is ready. - **Redaction is heuristic** — a conservative backstop, not a guarantee; secrets belong in `.env`, which is never read or reported. diff --git a/packages/sdk/telemetry/README.zh.md b/packages/sdk/telemetry/README.zh.md index af54cc2c78..bfb154e4c7 100644 --- a/packages/sdk/telemetry/README.zh.md +++ b/packages/sdk/telemetry/README.zh.md @@ -14,7 +14,7 @@ Consent 由 `cordis.yml` 中的 telemetry 配置项承载,因此禁用 telemetry 就是禁用该配置项。telemetry 默认上报,只有已经存在的 telemetry 配置项被显式设为 `disabled` 时才关闭:缺少 `cordis.yml`(首次 `create`)、配置项已启用,或 `cordis.yml` 中没有 telemetry 配置项时都会上报。`DO_NOT_TRACK`/CI 始终拒绝。无配置与缺少配置项的默认值可以通过 `ConsentResolver` 配置。 -收集端点是固定常量(`DSH_TELEMETRY_ENDPOINT`);发布前必须将其 `.invalid` 占位值替换为真实端点。 +收集端点是固定常量(`DSH_TELEMETRY_ENDPOINT`);[#1973](https://github.com/deepseek-harness/deepseek-harness/issues/1973) 跟踪服务部署,以及发布前将作为安全兜底的 `.invalid` 占位值替换为真实端点。 ## 模型体验 @@ -26,5 +26,5 @@ Consent 由 `cordis.yml` 中的 telemetry 配置项承载,因此禁用 telemet ## 已知限制与暂缓事项 -- **占位端点**:`DSH_TELEMETRY_ENDPOINT` 指向 `.invalid`,直到设置真实端点。 +- **占位端点**:`DSH_TELEMETRY_ENDPOINT` 指向 `.invalid`,直至 [#1973](https://github.com/deepseek-harness/deepseek-harness/issues/1973) 跟踪的服务就绪。 - **脱敏依赖启发式规则**:这只是保守后备,不是保证;密钥应存放于 `.env`,而该文件绝不会被读取或上报。 diff --git a/packages/sdk/telemetry/src/reporter.ts b/packages/sdk/telemetry/src/reporter.ts index d41c1db9b7..3ad7b4b62e 100644 --- a/packages/sdk/telemetry/src/reporter.ts +++ b/packages/sdk/telemetry/src/reporter.ts @@ -16,11 +16,10 @@ import { getOrCreateAnonymousId, type AnonymousId } from './anonymous-id.ts' import { SecretRedactor } from './secret-redactor.ts' /** - * Placeholder collection endpoint. This is a fixed protocol constant, not a - * deployment tunable. - * - * FIXME(ccyu): replace with the real telemetry endpoint before release. The - * `.invalid` TLD guarantees delivery fails harmlessly until then. + * Fail-safe placeholder collection endpoint. The `.invalid` TLD guarantees + * delivery fails harmlessly until the service tracked in + * https://github.com/deepseek-harness/deepseek-harness/issues/1973 is ready. + * This is a fixed protocol constant, not a deployment tunable. */ export const DSH_TELEMETRY_ENDPOINT = 'https://telemetry.example.invalid/v1/dsh-sdk' diff --git a/scripts/install.sh b/scripts/install.sh index 5c9d892f73..59184d91ab 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -47,8 +47,6 @@ # DSH_CURRENT stable symlink to the active worktree (default: $DSH_SOURCE/current) # DSH_BIN_DIR directory the `dsh` symlink lands in (default: ~/.local/bin) # DSH_HOME Harness home holding profiles and user patches (default: ~/.dsh) -# FIXME(install-ts): Move the post-checkout workflow into a tested TypeScript -# entrypoint; keep this POSIX shell file as the curl/source bootstrap. set -eu DSH_REF=${DSH_REF:-master} 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 038/109] 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 62c308f4157d1b30cb5be9ad56e70c721c016e10 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 00:40:06 +0800 Subject: [PATCH 039/109] feat(skill): share renderSkillContent and declare the skill-invocation message source The model-facing rendering moves from dsh-tool-skill to the dsh-skill seam so the skill tool result and the upcoming user-explicit invocation injection share one canonical shape. The seam also declares the skill-invocation MessageSource kind that injection will stamp on its user-role messages. --- packages/skill/skill/package.json | 2 + packages/skill/skill/src/index.ts | 91 ++++++++++++++++++++++++ packages/skill/skill/tests/skill.spec.ts | 63 ++++++++++++++++ packages/skill/skill/tsconfig.json | 3 + packages/skill/tool-skill/src/index.ts | 59 +-------------- 5 files changed, 162 insertions(+), 56 deletions(-) diff --git a/packages/skill/skill/package.json b/packages/skill/skill/package.json index 73469b89a7..f77f56f6d1 100644 --- a/packages/skill/skill/package.json +++ b/packages/skill/skill/package.json @@ -26,6 +26,7 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "dependencies": { @@ -33,6 +34,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/skill/skill/src/index.ts b/packages/skill/skill/src/index.ts index 32f7112542..f44386d51c 100644 --- a/packages/skill/skill/src/index.ts +++ b/packages/skill/skill/src/index.ts @@ -10,6 +10,7 @@ */ import { Context, Service } from 'cordis' +import { assertNever } from '@deepseek-ai/dsh-llm' import z from 'schemastery' import type Schema from 'schemastery' @@ -119,6 +120,96 @@ export function isUserInvocable(skill: Pick): boolea return skill.invocation.userInvocable } +/** + * Durable message source for a user-explicit skill invocation: the host + * injects the rendered skill as a user-role message carrying this source, so + * transcript consumers present the invocation from metadata instead of + * re-parsing the model-facing text. + */ +export interface SkillInvocationSource { + readonly kind: 'skill-invocation' + /** Invoked skill name, validated user-invocable at the injecting boundary. */ + readonly name: string + /** Trailing free text the user submitted after the skill token, when present. */ + readonly args?: string +} + +declare module '@deepseek-ai/dsh-llm' { + interface MessageSourceMap { + /** A user-explicit skill invocation injected by the host. */ + 'skill-invocation': SkillInvocationSource + } +} + +/** + * Render one loaded skill for the model. The output is shared verbatim by the + * `skill` tool result and the user-explicit invocation injection, so the model + * sees one canonical `` shape on both paths. The name rides an + * escaped attribute; the body is embedded verbatim (skills are trusted local + * content, and user-supplied invocation text stays outside this wrapper). + * @param skill - name, provider, optional resource base, and body to render. + * @returns the complete model-facing `` block. + */ +export function renderSkillContent(skill: Pick): string { + const resourceHint = renderResourceHint(skill) + return [ + ``, + '', + ...resourceHint, + '', + '', + '', + skill.content, + '', + '', + ].join('\n') +} + +function renderResourceHint(skill: Pick): string[] { + const base = skill.resourceBase + if (base === undefined) { + return [ + `Resources for this skill are managed by provider "${escapeText(skill.provider)}".`, + 'Load referenced resources only as needed.', + ] + } + switch (base.kind) { + case 'directory': + return [ + `Base directory for this skill: ${escapeText(base.path)}`, + 'Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.', + ] + case 'url': + return [ + `Base URL for this skill: ${escapeText(base.url)}`, + 'Resolve relative URLs mentioned by this skill against the base URL before using them. Load referenced resources only as needed.', + ] + case 'opaque': + return [ + `Resources for this skill: ${escapeText(base.description)}`, + 'Load referenced resources only as needed.', + ] + /* v8 ignore start -- SkillResourceBase is a closed union; a future kind must fail compilation here. */ + default: + return assertNever(base, 'SkillResourceBase.kind') + /* v8 ignore stop */ + } +} + +function escapeAttr(value: string): string { + return value.replaceAll('&', '&').replaceAll('"', '"').replaceAll('<', '<') +} + +/** + * Escape model-facing prose embedded inside skill markup so provider-supplied + * text cannot open or close framing tags. + * @param value - raw prose to embed. + * @returns the escaped text. + */ +export function escapeText(value: string): string { + return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>') +} + /** One catalog observation plus whether discovery completed within a stable catalog revision. */ export interface SkillCatalogSnapshot { /** Sorted invocation-neutral summaries collected in this observation. */ diff --git a/packages/skill/skill/tests/skill.spec.ts b/packages/skill/skill/tests/skill.spec.ts index 39ef384f3c..d48263cfe0 100644 --- a/packages/skill/skill/tests/skill.spec.ts +++ b/packages/skill/skill/tests/skill.spec.ts @@ -3,6 +3,7 @@ import { Context } from 'cordis' import SkillService, { isModelInvocable, isUserInvocable, + renderSkillContent, type SkillCandidate, type SkillDefinition, type SkillInvocationPolicy, @@ -1013,3 +1014,65 @@ describe('SkillService registry', () => { expect(await ctx.skills.get('same-skill')).toBeUndefined() }) }) + +describe('renderSkillContent', () => { + it('renders a directory-based skill with the shared wrapper', () => { + const text = renderSkillContent({ + name: 'demo-skill', + provider: 'memory', + resourceBase: { kind: 'directory', path: '/tmp/demo' }, + content: 'Do the thing.', + }) + expect(text).toBe([ + '', + '', + 'Base directory for this skill: /tmp/demo', + 'Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.', + '', + '', + '', + 'Do the thing.', + '', + '', + ].join('\n')) + }) + + it('renders url and opaque resource hints', () => { + const url = renderSkillContent({ + name: 'url-skill', + provider: 'memory', + resourceBase: { kind: 'url', url: 'https://example.test/base/' }, + content: 'Body.', + }) + expect(url).toContain('Base URL for this skill: https://example.test/base/') + expect(url).toContain('Resolve relative URLs mentioned by this skill against the base URL before using them.') + + const opaque = renderSkillContent({ + name: 'opaque-skill', + provider: 'memory', + resourceBase: { kind: 'opaque', description: 'archive ' }, + content: 'Body.', + }) + expect(opaque).toContain('Resources for this skill: archive <bundle>') + }) + + it('falls back to the provider hint without a resource base', () => { + const text = renderSkillContent({ + name: 'provider-skill', + provider: 'remote ', + content: 'Body.', + }) + expect(text).toContain('Resources for this skill are managed by provider "remote <hub>".') + }) + + it('escapes hostile attribute names and keeps the body verbatim', () => { + const text = renderSkillContent({ + name: 'x"& and as-is.', + }) + expect(text).toContain('') + expect(text).toContain('Keep and as-is.') + }) +}) diff --git a/packages/skill/skill/tsconfig.json b/packages/skill/skill/tsconfig.json index e882ed2d72..82e62d7c91 100644 --- a/packages/skill/skill/tsconfig.json +++ b/packages/skill/skill/tsconfig.json @@ -15,6 +15,9 @@ { "path": "../../../vendor/schemastery" }, + { + "path": "../../llm/llm" + }, { "path": "../../support/invariants" } diff --git a/packages/skill/tool-skill/src/index.ts b/packages/skill/tool-skill/src/index.ts index ddc45d18e9..19e154143d 100644 --- a/packages/skill/tool-skill/src/index.ts +++ b/packages/skill/tool-skill/src/index.ts @@ -9,12 +9,13 @@ import type { Context } from 'cordis' import z from 'schemastery' import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { defineTool } from '@deepseek-ai/dsh-tools' -import { assertNever, createUserMessage } from '@deepseek-ai/dsh-llm' +import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { UserMessage } from '@deepseek-ai/dsh-session' import { + escapeText, isModelInvocable, isSkillName, - type SkillDefinition, + renderSkillContent, type SkillSummary, } from '@deepseek-ai/dsh-skill' @@ -203,52 +204,6 @@ export function apply(ctx: Context, config: Config = {}): void { }) } -function renderSkillContent(skill: Pick): string { - const resourceHint = renderResourceHint(skill) - return [ - ``, - '', - ...resourceHint, - '', - '', - '', - skill.content, - '', - '', - ].join('\n') -} - -function renderResourceHint(skill: Pick): string[] { - const base = skill.resourceBase - if (base === undefined) { - return [ - `Resources for this skill are managed by provider "${escapeText(skill.provider)}".`, - 'Load referenced resources only as needed.', - ] - } - switch (base.kind) { - case 'directory': - return [ - `Base directory for this skill: ${escapeText(base.path)}`, - 'Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.', - ] - case 'url': - return [ - `Base URL for this skill: ${escapeText(base.url)}`, - 'Resolve relative URLs mentioned by this skill against the base URL before using them. Load referenced resources only as needed.', - ] - case 'opaque': - return [ - `Resources for this skill: ${escapeText(base.description)}`, - 'Load referenced resources only as needed.', - ] - /* v8 ignore start -- SkillResourceBase is a closed union; a future kind must fail compilation here. */ - default: - return assertNever(base, 'SkillResourceBase.kind') - /* v8 ignore stop */ - } -} - function renderCatalogMessage(entries: SkillCatalogSource['entries']): UserMessage { return createUserMessage({ content: [{ @@ -393,11 +348,3 @@ function assertPositiveInteger(name: string, value: number, minimum = 1): void { throw new Error(`tool-skill: ${name} must be an integer greater than or equal to ${minimum}`) } } - -function escapeAttr(value: string): string { - return value.replaceAll('&', '&').replaceAll('"', '"').replaceAll('<', '<') -} - -function escapeText(value: string): string { - return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>') -} From 85422f44dc512ee5365f513b0aa5f44e11c62ddf Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 00:51:24 +0800 Subject: [PATCH 040/109] feat(host): user-invocable skill listing and skill.invoke injection RPC skill.list now serves every user-invocable skill and carries modelInvocable so menus can mark user-only entries; the old model-and-user intersection hid disable-model-invocation skills from their only legitimate entry point (issue #1470). skill.invoke enforces user-invocation policy at the host boundary, renders the canonical body, and injects it as a user-role message carrying the skill-invocation source before starting a turn. The connection fixture mirrors both faces for client tests. --- .../client/connection/src/client/fixture.ts | 21 +++- packages/host/apiproxy/src/api-proxy.ts | 58 ++++++++- packages/host/apiproxy/src/api/rpc-map.ts | 1 + packages/host/apiproxy/src/api/rpc.schema.ts | 2 + packages/host/apiproxy/src/api/rpc.ts | 4 + .../host/apiproxy/src/api/skills.schema.ts | 13 ++ packages/host/apiproxy/src/api/skills.ts | 18 ++- packages/host/apiproxy/src/fetch/client.ts | 5 +- packages/host/apiproxy/src/fetch/handler.ts | 3 +- .../apiproxy/tests/api-proxy-commands.spec.ts | 116 +++++++++++++++++- .../apiproxy/tests/client-handler.spec.ts | 2 +- .../host/apiproxy/tests/fetch-carrier.spec.ts | 9 +- .../host/apiproxy/tests/rpc-schemas.spec.ts | 25 +++- 13 files changed, 261 insertions(+), 16 deletions(-) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 776d21fd46..75653d43e3 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -2449,10 +2449,28 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { if (missing !== undefined) return missing return ok(request, { skills: [ - { name: 'fixture-demo', description: 'fixture 技能样本', whenToUse: '仅供 UI 目录渲染验收' }, + { name: 'fixture-demo', description: 'fixture 技能样本', whenToUse: '仅供 UI 目录渲染验收', modelInvocable: true }, + { name: 'fixture-user-only', description: 'fixture 仅用户技能样本', modelInvocable: false }, ], }) }, + invoke: (request) => { + const missing = requireSession(request) + if (missing !== undefined) return missing + const { sessionId, name, text: args } = request.payload + const body = `\n\nBase directory for this skill: /fixture/skills/${name}\n\n\n\nFixture ${name} instructions.\n\n` + // Mirror the host: injection is a user-role message carrying the + // skill-invocation source, immediately visible in the transcript. + // The client program cannot see the host-side MessageSourceMap merge + // (sources are opaque wire JSON to the UI), so the fixture stamps the + // durable shape through the same assertion the projections read back. + const source = { kind: 'skill-invocation', name, ...args === undefined ? {} : { args } } as unknown as MessageSource + append(sessionId, { + type: 'user/message', surfaceOp: 'append', + data: userMessage(text(args === undefined ? body : `${body}\n\n${args}`), source), + }) + return ok(request, { accepted: true as const }) + }, }, goals: { // Compatibility face only: old API Proxy payloads and acknowledgements @@ -2761,6 +2779,7 @@ export class FixtureApiClient extends AbstractApiClient { case 'command.list': return this.api.commands.list(request) case 'command.execute': return this.api.commands.execute(request, signal) case 'skill.list': return this.api.skills.list(request) + case 'skill.invoke': return this.api.skills.invoke(request) case 'goal.create': return this.api.goals.create(request) case 'goal.edit': return this.api.goals.edit(request) case 'goal.pause': return this.api.goals.pause(request) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index e4b715a0c4..6384a4d408 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -18,6 +18,8 @@ import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-session-query' import { SubagentError } from '@deepseek-ai/dsh-subagent' import type { SubagentListEntry as CatalogSubagentListEntry } from '@deepseek-ai/dsh-subagent' +import { isSkillName, isUserInvocable, renderSkillContent } from '@deepseek-ai/dsh-skill' +import type { SkillInvocationSource } from '@deepseek-ai/dsh-skill' import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace' import { workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId, @@ -2359,19 +2361,71 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro return err(request, { code: 'internal', message: 'skill registry is absent: this deployment does not mount @deepseek-ai/dsh-skill in its composition (cordis.yml or explicit assembly)', details: {} }) } try { - const skills = (await skillRegistry.list({ cwd })) - .filter(skill => skill.invocation.modelInvocable && skill.invocation.userInvocable) + const skills = (await skillRegistry.list({ cwd })).filter(isUserInvocable) return ok(request, { skills: skills.map(skill => ({ name: skill.name, description: skill.description, ...skill.whenToUse === undefined ? {} : { whenToUse: skill.whenToUse }, + modelInvocable: skill.invocation.modelInvocable, })), }) } catch (error: unknown) { return err(request, { code: 'internal', message: `skill listing failed: ${String(error)}`, details: {} }) } }, + + async invoke(request) { + const { sessionId, name, text } = request.payload + const found = await agentFor(sessionId) + if ('error' in found) return err(request, found.error) + const agent = found.agent + // Same turn-start refusal boundary as sessions.prompt: injection + // starts a turn, so a route no adapter serves is refused while the + // composer still shows the draft. + const target = targetFor(agent).current + if (!routeServed(target.provider)) { + return err(request, { + code: 'model-unavailable', + message: `no adapter serves provider "${target.provider}"; select a model for this session`, + details: { provider: target.provider, model: target.model }, + }) + } + const skillRegistry = ctx.get('skills') + if (skillRegistry === undefined) { + return err(request, { code: 'internal', message: 'skill registry is absent: this deployment does not mount @deepseek-ai/dsh-skill in its composition (cordis.yml or explicit assembly)', details: {} }) + } + const lookup = { cwd: agent.session.header.cwd } + // isSkillName guards the registry contract; an ill-formed name is + // indistinguishable from an absent one for the caller. + const summary = isSkillName(name) + ? (await skillRegistry.list(lookup)).find(skill => skill.name === name) + : undefined + if (summary === undefined) { + return err(request, { code: 'skill-not-found', message: `skill "${name}" is unknown in this workspace`, details: { name } }) + } + // The operation boundary owns user-invocation policy: client menus + // filtering their candidates is an affordance, not enforcement. + if (!isUserInvocable(summary)) { + return err(request, { code: 'skill-not-invocable', message: `skill "${name}" is not available for user invocation`, details: { name } }) + } + const skill = await skillRegistry.get(name, lookup) + if (skill === undefined) { + return err(request, { code: 'skill-not-found', message: `skill "${name}" is unknown in this workspace`, details: { name } }) + } + const body = renderSkillContent(skill) + const source: SkillInvocationSource = { kind: 'skill-invocation', name, ...text === undefined ? {} : { args: text } } + try { + const message: UserMessage = createUserMessage({ + content: [{ type: 'text', text: text === undefined ? body : `${body}\n\n${text}` }], + source, + }) + agent.followup(message) + } catch (error: unknown) { + return err(request, { code: 'agent-busy', message: 'skill invocation rejected', details: { reason: String(error) } }) + } + return ok(request, { accepted: true as const }) + }, }, settings: { diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index 9a8750c722..b001d54625 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -50,6 +50,7 @@ export interface RpcMethodMap { 'command.list': CommandsApi['list'] 'command.execute': CommandsApi['execute'] 'skill.list': SkillsApi['list'] + 'skill.invoke': SkillsApi['invoke'] 'goal.create': GoalsApi['create'] 'goal.edit': GoalsApi['edit'] 'goal.pause': GoalsApi['pause'] diff --git a/packages/host/apiproxy/src/api/rpc.schema.ts b/packages/host/apiproxy/src/api/rpc.schema.ts index 2733c6e940..dd3fe7cf57 100644 --- a/packages/host/apiproxy/src/api/rpc.schema.ts +++ b/packages/host/apiproxy/src/api/rpc.schema.ts @@ -51,6 +51,8 @@ export const rpcErrorSchema: z.ZodType = z.discriminatedUnion('code', z.object({ code: z.literal('steer-unavailable'), message: z.string(), details: z.object({ itemId: z.string() }) }), z.object({ code: z.literal('command-error'), message: z.string(), details: z.object({}) }), z.object({ code: z.literal('unknown-command'), message: z.string(), details: z.object({}) }), + z.object({ code: z.literal('skill-not-found'), message: z.string(), details: z.object({ name: z.string() }) }), + z.object({ code: z.literal('skill-not-invocable'), message: z.string(), details: z.object({ name: z.string() }) }), z.object({ code: z.literal('settings-rejected'), message: z.string(), details: z.object({ ns: z.string() }) }), z.object({ code: z.literal('settings-not-exposed'), message: z.string(), details: z.object({ ns: z.string() }) }), z.object({ code: z.literal('settings-conflict'), message: z.string(), details: z.object({ ns: z.string(), expected: z.number(), actual: z.number() }) }), diff --git a/packages/host/apiproxy/src/api/rpc.ts b/packages/host/apiproxy/src/api/rpc.ts index 54bbb5a8cc..7bf41a32e1 100644 --- a/packages/host/apiproxy/src/api/rpc.ts +++ b/packages/host/apiproxy/src/api/rpc.ts @@ -51,6 +51,10 @@ export interface RpcErrorDetailsMap { 'command-error': {} /** A leading-/ prompt named no registered command; the message names the token. */ 'unknown-command': {} + /** A skill invocation named no skill in the session's workspace (unknown or ill-formed name). */ + 'skill-not-found': { name: string } + /** A skill invocation named a skill whose policy forbids user invocation. */ + 'skill-not-invocable': { name: string } /** * A settings write was refused (schema validation, unknown namespace, * read-only provider, or storage failure); the message is the seam's text. diff --git a/packages/host/apiproxy/src/api/skills.schema.ts b/packages/host/apiproxy/src/api/skills.schema.ts index 3bf7ad429a..c1ee1024a3 100644 --- a/packages/host/apiproxy/src/api/skills.schema.ts +++ b/packages/host/apiproxy/src/api/skills.schema.ts @@ -14,6 +14,7 @@ export const skillEntrySchema = z.object({ name: z.string().min(1), description: z.string(), whenToUse: z.string().optional(), + modelInvocable: z.boolean(), }) satisfies z.ZodType> /** skill.list request payload. */ @@ -25,3 +26,15 @@ export const skillListRequestSchema = z.object({ export const skillListValueSchema = z.object({ skills: z.array(skillEntrySchema), }) satisfies z.ZodType>> + +/** skill.invoke request payload. */ +export const skillInvokeRequestSchema = z.object({ + sessionId: sessionIdSchema, + name: z.string().min(1), + text: z.string().optional(), +}) satisfies z.ZodType>> + +/** skill.invoke response value. */ +export const skillInvokeValueSchema = z.object({ + accepted: z.literal(true), +}) satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/skills.ts b/packages/host/apiproxy/src/api/skills.ts index 33802dd4c0..2ade72efb9 100644 --- a/packages/host/apiproxy/src/api/skills.ts +++ b/packages/host/apiproxy/src/api/skills.ts @@ -10,16 +10,28 @@ import type { RpcRequest, RpcResponse } from './rpc.ts' /** Skill catalog row (wire projection of the host SkillSummary; provider/source vocabulary stays host-side). */ export interface SkillEntry { - /** Kebab-case identifier referenced as `name` in prompts. */ + /** Kebab-case identifier the user references as `/name` in the composer. */ readonly name: string /** Short routing description. */ readonly description: string /** Optional extra routing guidance. */ readonly whenToUse?: string + /** False marks a user-only skill (`disable-model-invocation`): invocable here, absent from the model catalog. */ + readonly modelInvocable: boolean } -/** Skill-domain unary methods (the map key skill.* of RpcMethodMap). */ +/** Skill-domain unary methods (the map keys skill.* of RpcMethodMap). */ export interface SkillsApi { - /** Lists skills usable by the browser's user-selected model-reference path. */ + /** Lists the user-invocable skill catalog for the session's project. */ list(request: RpcRequest<{ sessionId: SessionId }>): Promise> + + /** + * Injects one user-invocable skill into the addressed agent as a user-role + * message (the canonical `` rendering, with `text` appended + * when present) and starts a turn. The host enforces user-invocation policy + * here: a model-only or unknown name is refused regardless of what a client + * menu offered. Session-backed subagents reject with `agent-busy`. + */ + invoke(request: RpcRequest<{ sessionId: SessionId; name: string; text?: string }>): + Promise> } diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 0f54d76dbc..574206458b 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -39,7 +39,7 @@ import { workspaceRenameValueSchema, } from '../api/workspace.schema.ts' import { commandExecuteValueSchema, commandListValueSchema } from '../api/commands.schema.ts' -import { skillListValueSchema } from '../api/skills.schema.ts' +import { skillInvokeValueSchema, skillListValueSchema } from '../api/skills.schema.ts' import { goalCreateValueSchema, goalEditValueSchema, @@ -118,6 +118,7 @@ export interface IApiClient { } skills: { list(payload: RequestPayload<'skill.list'>, signal?: AbortSignal): Promise>> + invoke(payload: RequestPayload<'skill.invoke'>, signal?: AbortSignal): Promise>> } events: { mux(payload: Parameters[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable> @@ -185,6 +186,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType this.callUnary('skill.list', payload, signal), + invoke: (payload, signal) => this.callUnary('skill.invoke', payload, signal), } readonly goals: IApiClient['goals'] = { diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index d41b51ad6d..914c425e91 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -41,7 +41,7 @@ import { workspaceRenameRequestSchema, } from '../api/workspace.schema.ts' import { commandExecuteRequestSchema, commandListRequestSchema } from '../api/commands.schema.ts' -import { skillListRequestSchema } from '../api/skills.schema.ts' +import { skillInvokeRequestSchema, skillListRequestSchema } from '../api/skills.schema.ts' import { goalCreateRequestSchema, goalEditRequestSchema, @@ -109,6 +109,7 @@ const UNARY_ROUTES: UnaryRoutes = { 'command.list': { schema: commandListRequestSchema, invoke: (api, r) => api.commands.list(r) }, 'command.execute': { schema: commandExecuteRequestSchema, invoke: (api, r, signal) => api.commands.execute(r, signal) }, 'skill.list': { schema: skillListRequestSchema, invoke: (api, r) => api.skills.list(r) }, + 'skill.invoke': { schema: skillInvokeRequestSchema, invoke: (api, r) => api.skills.invoke(r) }, 'goal.create': { schema: goalCreateRequestSchema, invoke: (api, r) => api.goals.create(r) }, 'goal.edit': { schema: goalEditRequestSchema, invoke: (api, r) => api.goals.edit(r) }, 'goal.pause': { schema: goalPauseRequestSchema, invoke: (api, r) => api.goals.pause(r) }, diff --git a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts index 55781a3e77..7d7062023e 100644 --- a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts @@ -228,7 +228,10 @@ describe('skill.list', () => { // touch (or resume through) the Agent registry. const session = ctx.sessions.create(undefined, { meta: { cwd: '/proj' } }) const value = expectOk(await api.skills.list(request({ sessionId: session.id }))) - expect(value.skills).toEqual([{ name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing' }]) + expect(value.skills).toEqual([ + { name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing', modelInvocable: true }, + { name: 'user-only', description: 'User-only', modelInvocable: false }, + ]) expect(seenCwds).toEqual(['/proj']) expect(ctx.agents.get(session.id)).toBeUndefined() }) @@ -266,6 +269,117 @@ describe('skill.list', () => { }) }) +describe('skill.invoke', () => { + /** Provider with one user-only and one model-only skill, both loadable. */ + function registerInvokeSkills(ctx: Context): void { + const summaries = [ + { + name: 'user-only', description: 'User-only', + invocation: { modelInvocable: false, userInvocable: true }, + source: 'custom', provider: 'probe', rank: 0, locator: null, + resourceBase: { kind: 'directory', path: '/proj/.agents/skills/user-only' }, + }, + { + name: 'model-only', description: 'Model-only', + invocation: { modelInvocable: true, userInvocable: false }, + source: 'custom', provider: 'probe', rank: 0, locator: null, + }, + ] as const + ctx.skills.registerProvider(() => ({ + name: 'probe', + list: () => Promise.resolve(summaries.map(summary => ({ ...summary }))), + get: candidate => Promise.resolve({ + ...summaries.find(summary => summary.name === candidate.name)!, + content: 'Follow the probe instructions.', + }), + })) + } + + /** Agent stub whose session carries a project cwd and whose followup records the injected message. */ + function invokableAgent(ctx: Context): { agent: Agent; followup: ReturnType } { + const session = ctx.sessions.create(undefined, { meta: { cwd: '/proj' } }) + const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) + const followup = vi.fn() + const agent = { id: session.id, session, inbox, status: 'idle', ctx, followup } as unknown as Agent + ctx.agents.register(agent) + return { agent, followup } + } + + it('injects a user-invocable skill as a user message with the invocation source', async () => { + const ctx = await harness() + registerInvokeSkills(ctx) + const api = createApiProxy(ctx, DEFAULTS) + const { agent, followup } = invokableAgent(ctx) + const value = expectOk(await api.skills.invoke(request({ + sessionId: agent.id, name: 'user-only', text: 'and check the fixture', + }))) + expect(value).toEqual({ accepted: true }) + expect(followup).toHaveBeenCalledTimes(1) + const message = followup.mock.calls[0]?.[0] as UserMessage + expect(message.source).toEqual({ kind: 'skill-invocation', name: 'user-only', args: 'and check the fixture' }) + expect(message.content).toHaveLength(1) + const text = (message.content[0] as { text: string }).text + expect(text).toContain('') + expect(text).toContain('Base directory for this skill: /proj/.agents/skills/user-only') + expect(text).toContain('Follow the probe instructions.') + expect(text.endsWith('\n\nand check the fixture')).toBe(true) + }) + + it('omits args from the source and content when no text rides the invocation', async () => { + const ctx = await harness() + registerInvokeSkills(ctx) + const api = createApiProxy(ctx, DEFAULTS) + const { agent, followup } = invokableAgent(ctx) + expectOk(await api.skills.invoke(request({ sessionId: agent.id, name: 'user-only' }))) + const message = followup.mock.calls[0]?.[0] as UserMessage + expect(message.source).toEqual({ kind: 'skill-invocation', name: 'user-only' }) + const text = (message.content[0] as { text: string }).text + expect(text.endsWith('')).toBe(true) + }) + + it('rejects a skill the user may not invoke', async () => { + const ctx = await harness() + registerInvokeSkills(ctx) + const api = createApiProxy(ctx, DEFAULTS) + const { agent, followup } = invokableAgent(ctx) + const error = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'model-only' }))) + expect(error.code).toBe('skill-not-invocable') + expect(followup).not.toHaveBeenCalled() + }) + + it('rejects an unknown or invalid skill name', async () => { + const ctx = await harness() + registerInvokeSkills(ctx) + const api = createApiProxy(ctx, DEFAULTS) + const { agent } = invokableAgent(ctx) + const missing = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'absent-skill' }))) + expect(missing.code).toBe('skill-not-found') + const invalid = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'Not A Name' }))) + expect(invalid.code).toBe('skill-not-found') + }) + + it('surfaces a followup refusal as agent-busy', async () => { + const ctx = await harness() + registerInvokeSkills(ctx) + const api = createApiProxy(ctx, DEFAULTS) + const { agent, followup } = invokableAgent(ctx) + followup.mockImplementation(() => { throw new Error('inbox closed') }) + const error = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'user-only' }))) + expect(error.code).toBe('agent-busy') + }) + + it('fails loud with internal when the skill registry is not mounted', async () => { + const ctx = await harness({ skills: false }) + const api = createApiProxy(ctx, DEFAULTS) + const session = ctx.sessions.create(undefined, { meta: { cwd: '/proj' } }) + const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) + ctx.agents.register({ id: session.id, session, inbox, status: 'idle', ctx, followup: vi.fn() } as unknown as Agent) + const error = expectErr(await api.skills.invoke(request({ sessionId: session.id, name: 'user-only' }))) + expect(error.code).toBe('internal') + expect(error.message).toContain('skill registry is absent') + }) +}) + describe('host/commands-changed frame', () => { it('broadcasts on registry change', async () => { const ctx = await harness() diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index ebd56ee551..0a65c817c6 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -86,7 +86,7 @@ function scriptedApi(overrides: { execute: r => ok(r, { matched: false }), ...overrides.commands, }, - skills: { list: r => ok(r, { skills: [] }), ...overrides.skills }, + skills: { list: r => ok(r, { skills: [] }), invoke: r => ok(r, { accepted: true as const }), ...overrides.skills }, goals: { create: err, edit: err, diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 22e1650f5b..09cabdcc7f 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -196,7 +196,10 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra }, skills: { async list(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits' }] } } } + return { rpcId: request.rpcId, result: { ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits', modelInvocable: true }] } } } + }, + async invoke(request) { + return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } } }, }, goals: { @@ -381,7 +384,9 @@ describe('unary round trip (handler ⇄ client, no network)', () => { const miss = await c.commands.execute({ sessionId: 's' as never, line: '/nope' }) expect(miss.result).toEqual({ ok: true, value: { matched: false } }) const skills = await c.skills.list({ sessionId: 's' as never }) - expect(skills.result).toEqual({ ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits' }] } }) + expect(skills.result).toEqual({ ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits', modelInvocable: true }] } }) + const invoked = await c.skills.invoke({ sessionId: 's' as never, name: 'commit-helper', text: 'go' }) + expect(invoked.result).toEqual({ ok: true, value: { accepted: true } }) }) it('lets command.execute finish after the 30-second default unary deadline', async () => { diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 28f9138502..253ac92fdf 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -31,7 +31,7 @@ import { commandDescriptorSchema, commandExecuteRequestSchema, commandExecuteValueSchema, commandListRequestSchema, commandListValueSchema, } from '../src/api/commands.schema.ts' -import { skillEntrySchema, skillListRequestSchema, skillListValueSchema } from '../src/api/skills.schema.ts' +import { skillEntrySchema, skillInvokeRequestSchema, skillInvokeValueSchema, skillListRequestSchema, skillListValueSchema } from '../src/api/skills.schema.ts' import { hostFrameSchema, muxFrameSchema, askUserQuestionItemSchema } from '../src/api/events.schema.ts' import { approvalRequestIdSchema, approvalResponsePayloadSchema } from '../src/api/approvals.schema.ts' import { askUserQuestionAnswerSchema, questionResponsePayloadSchema } from '../src/api/questions.schema.ts' @@ -74,6 +74,8 @@ describe('rpcErrorSchema', () => { expect(rpcErrorSchema.parse({ code: 'queue-item-not-found', message: 'm', details: { itemId: 'i' } }).code).toBe('queue-item-not-found') expect(rpcErrorSchema.parse({ code: 'command-error', message: 'm', details: {} }).code).toBe('command-error') expect(rpcErrorSchema.parse({ code: 'unknown-command', message: 'm', details: {} }).code).toBe('unknown-command') + expect(rpcErrorSchema.parse({ code: 'skill-not-found', message: 'm', details: { name: 'n' } }).code).toBe('skill-not-found') + expect(rpcErrorSchema.parse({ code: 'skill-not-invocable', message: 'm', details: { name: 'n' } }).code).toBe('skill-not-invocable') expect(rpcErrorSchema.parse({ code: 'title-invalid', message: 'm', details: { sessionId: 's' } }).code).toBe('title-invalid') expect(rpcErrorSchema.parse({ code: 'internal', message: 'm', details: {} }).code).toBe('internal') }) @@ -81,6 +83,7 @@ describe('rpcErrorSchema', () => { it('rejects a known code with missing details', () => { expect(() => rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: {} })).toThrow() expect(() => rpcErrorSchema.parse({ code: 'title-invalid', message: 'm', details: {} })).toThrow() + expect(() => rpcErrorSchema.parse({ code: 'skill-not-invocable', message: 'm', details: {} })).toThrow() expect(() => rpcErrorSchema.parse({ code: 'command-error', message: 'm' })).toThrow() expect(() => rpcErrorSchema.parse({ code: 'nope', message: 'm', details: {} })).toThrow() }) @@ -395,12 +398,26 @@ describe('skills domain schemas', () => { expect(() => skillListRequestSchema.parse({})).toThrow() expect(skillListValueSchema.parse({ skills: [] }).skills).toEqual([]) const value = skillListValueSchema.parse({ skills: [ - { name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing' }, - { name: 'bare', description: 'No guidance' }, + { name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing', modelInvocable: true }, + { name: 'bare', description: 'No guidance', modelInvocable: false }, ] }) expect(value.skills[0]?.whenToUse).toBe('when committing') expect(value.skills[1]?.whenToUse).toBeUndefined() - expect(() => skillEntrySchema.parse({ name: '', description: 'd' })).toThrow() + expect(value.skills[1]?.modelInvocable).toBe(false) + expect(() => skillEntrySchema.parse({ name: '', description: 'd', modelInvocable: true })).toThrow() + // modelInvocable is required wire data: an entry without it fails. + expect(() => skillEntrySchema.parse({ name: 'n', description: 'd' })).toThrow() + }) + + it('validates the invoke request/value pair', () => { + expect(skillInvokeRequestSchema.parse({ sessionId: 's1', name: 'user-only' })) + .toEqual({ sessionId: 's1', name: 'user-only' }) + expect(skillInvokeRequestSchema.parse({ sessionId: 's1', name: 'user-only', text: 'check it' }).text) + .toBe('check it') + expect(() => skillInvokeRequestSchema.parse({ sessionId: 's1', name: '' })).toThrow() + expect(() => skillInvokeRequestSchema.parse({ name: 'user-only' })).toThrow() + expect(skillInvokeValueSchema.parse({ accepted: true })).toEqual({ accepted: true }) + expect(() => skillInvokeValueSchema.parse({ accepted: false })).toThrow() }) }) From 0490f8bb0621cb681c9b7c219ffef1c79247db94 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 00:51:54 +0800 Subject: [PATCH 041/109] feat(llm-pi-ai): per-model reasoningEfforts and reasoning-dispatch compat switches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A model entry's reasoningEfforts dict declares its selectable thinking levels — key = offered level, value = the wire spelling dispatch sends; only off may leave the value empty (supported, send nothing). false strips reasoning from a catalog model; every level is materialized explicitly into pi-ai's thinkingLevelMap so nobody has to know pi-ai's asymmetric absent-key defaulting. compat.thinkingFormat and compat.supportsReasoningEffort become configurable on the route and per model (model > route > catalog entry > pi-ai's URL-derived guess), openai-completions only, so a private gateway speaking the DeepSeek reasoning dialect no longer depends on its URL being recognizable. Record-typed drift gates pin both enums to pi-ai's, and an unserviceable declaration is refused at the write that produced it, naming route, model, and level. --- apps/web/tests/declared-reasoning.e2e.ts | 95 +++++++ apps/web/tests/declared-reasoning.overlay.yml | 8 + .../declared-reasoning/ui.expected.md | 7 + apps/web/tsconfig.json | 1 + packages/llm/llm-pi-ai/README.i18n.yaml | 4 +- packages/llm/llm-pi-ai/README.md | 31 ++- packages/llm/llm-pi-ai/README.zh.md | 31 ++- packages/llm/llm-pi-ai/src/catalog.ts | 239 +++++++++++++++++- packages/llm/llm-pi-ai/src/config.ts | 40 ++- packages/llm/llm-pi-ai/src/index.ts | 22 +- packages/llm/llm-pi-ai/tests/adapter.spec.ts | 143 +++++++++++ packages/llm/llm-pi-ai/tests/catalog.spec.ts | 175 ++++++++++++- packages/llm/llm-pi-ai/tests/config.spec.ts | 32 ++- tsconfig.host.json | 1 + 14 files changed, 806 insertions(+), 23 deletions(-) create mode 100644 apps/web/tests/declared-reasoning.e2e.ts create mode 100644 apps/web/tests/declared-reasoning.overlay.yml create mode 100644 apps/web/tests/snapshots/declared-reasoning/ui.expected.md diff --git a/apps/web/tests/declared-reasoning.e2e.ts b/apps/web/tests/declared-reasoning.e2e.ts new file mode 100644 index 0000000000..664f20dfe6 --- /dev/null +++ b/apps/web/tests/declared-reasoning.e2e.ts @@ -0,0 +1,95 @@ +// Web e2e scenario: a hand-declared model's `reasoningEfforts` reaches the +// composer's effort pane — the levels a settings profile declares are exactly +// what the picker offers, and picking one records it with the default route. +// Zero model calls: declaring, describing, and switching are settings/llm +// traffic only, so there is no fixture and a stray stream would fail loud. +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { settingsNamespace } from '@deepseek-ai/dsh-settings' +import { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, + launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts' + +/** Starts the shipped default on this scenario's declared reasoning model. */ +const OVERLAY = fileURLToPath(new URL('./declared-reasoning.overlay.yml', import.meta.url)) +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/declared-reasoning', import.meta.url)) +const UI_EXPECTED = fileURLToPath(new URL('./snapshots/declared-reasoning/ui.expected.md', import.meta.url)) +const MODE = webSnapshotMode() + +describe.skipIf(MODE === 'record')('web e2e: declared reasoning efforts reach the composer', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY }) + // The whole reasoning offer is the profile: key = selectable level, value + // = the wire spelling dispatch would send (`max: ultra` renames; the + // valueless `off` means "supported, send nothing"). The route sets no + // deployment default, so the pane leads with the provider-default entry. + await scaffold.ctx.settings.update(settingsNamespace('llm-pi-ai'), { + providers: { + 'acme-gateway': { + displayName: 'Acme Gateway', + api: 'openai-completions', + baseURL: 'https://gateway.acme.example/v1', + models: [{ + id: 'acme-think', + name: 'Acme Think', + reasoningEfforts: { off: null, high: 'high', max: 'ultra' }, + }], + }, + }, + }) + browser = await chromium.launch() + page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspaceZh(page, scaffold.workspaceCwd) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('offers exactly the declared levels and records the picked one', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-declared-reasoning')) + const trigger = page.getByRole('button', { name: /^选择模型/ }) + await trigger.waitFor({ timeout: 15_000 }) + await trigger.click() + await page.getByRole('menuitem', { name: /推理等级/ }).click() + + // Declared levels, nothing else: the provider-default entry (the route + // configures no `reasoning`), then Off/High/Max — minimal, low, medium, + // and xhigh were not declared and must not be offered. + const levels = page.getByRole('menuitemradio') + await expect.poll(async () => levels.allTextContents(), { timeout: 10_000 }) + .toEqual(['Default', 'Off', 'High', 'Max']) + const snapshot = await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + + // Picking a level is the same gesture that saves the default target, so + // the effort lands in the gateway's settings section beside the route. + await page.getByRole('menuitemradio', { name: 'High' }).click() + await expect.poll( + async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), + { timeout: 10_000 }, + ).toContain('reasoningEffort: high') + await expect.poll(() => trigger.getAttribute('aria-label'), { timeout: 10_000 }) + .toBe('选择模型,当前 Acme Think,推理等级 High') + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + + it('keeps its snapshot inventory closed', async () => { + await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md']) + }) +}) diff --git a/apps/web/tests/declared-reasoning.overlay.yml b/apps/web/tests/declared-reasoning.overlay.yml new file mode 100644 index 0000000000..d90452178c --- /dev/null +++ b/apps/web/tests/declared-reasoning.overlay.yml @@ -0,0 +1,8 @@ +# The fixture-less web scaffold registers no adapter, so the shipped +# deepseek-official default would be a route nothing serves. This scenario +# starts the default on its own declared reasoning model so the effort pane +# describes that model from the first open. +- id: api-gateway + config: + provider: acme-gateway + model: acme-think diff --git a/apps/web/tests/snapshots/declared-reasoning/ui.expected.md b/apps/web/tests/snapshots/declared-reasoning/ui.expected.md new file mode 100644 index 0000000000..810a6bf8b5 --- /dev/null +++ b/apps/web/tests/snapshots/declared-reasoning/ui.expected.md @@ -0,0 +1,7 @@ +- menu "模型与推理等级": + - menuitemradio "Default" [checked]: + - text: Default + - img + - menuitemradio "Off" + - menuitemradio "High" + - menuitemradio "Max" diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 528714a527..48275db0d6 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -38,6 +38,7 @@ "tests/settings-chrome.e2e.ts", "tests/models-settings.e2e.ts", "tests/default-model.e2e.ts", + "tests/declared-reasoning.e2e.ts", "tests/onboarding-deepseek-config.e2e.ts", "tests/remote-welcome.e2e.ts", "tests/workspace-management.e2e.ts", diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index b57043a84d..69efba1977 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: 97bd629adedda9d63fee730bc31129b0c22cc704 -README.zh.md: 71d45b590f48f4b8162ae329b58b5ff4a9eb13b1 +README.md: 894aecc720f0a7616c0127d439b41129d94ef667 +README.zh.md: 63464f80ee3036ddec3fb6828ecccc68c5524478 diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 97bd629ade..894aecc720 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -42,18 +42,41 @@ Configure credentials, the model catalog, and deployment-specific transport sett apiKeyEnv: ACME_GATEWAY_API_KEY api: openai-completions baseURL: https://gateway.acme.example/v1 + # Reasoning dialect for an endpoint whose URL pi-ai cannot recognize. + compat: + thinkingFormat: deepseek models: - id: acme-large name: Acme Large contextWindow: 65536 maxTokens: 4096 + - id: acme-think + name: Acme Think + contextWindow: 262144 + maxTokens: 32768 + # key = selectable level, value = its wire spelling; only off may + # leave the value empty (supported, send nothing). + reasoningEfforts: + off: + high: high + max: ultra ``` The dict shape makes duplicate routes unrepresentable, and the pre-release array shape (with per-profile `provider` fields) fails load with migration directions. `providers` may also be empty or omitted entirely: the adapter then mounts **dormant** — zero routes, no extra catalog entries — and registers routes the moment the `llm-pi-ai:` settings section supplies profiles, dropping them again when it empties. Dormant or not, the plugin declares every installed catalog provider in the configurable-provider directory (`ctx.llm.listConfigurableProviders()`, settings path `providers.`), joined with every route the current profiles declare, so configuration surfaces can offer the full catalog before any route exists and can still address a hand-declared one. Each entry carries `declared`: whether pi-ai ships nothing under that key. It follows the installed catalog, never the settings document, because narrowing a shipped provider's models stores a profile too and that route is still one pi-ai knows — only the adapter can tell the two apart, which is why the directory answers rather than leaving a surface to infer it. Which adapters exist is composition; which providers run can be entirely the user's settings document. Registration with `ctx.llm` is atomic: a collision with any provider route already owned by another adapter fails plugin loading without registering the remaining routes. Model ids are not lifecycle config; a model the route does not configure fails before any provider request with `LlmError('UNKNOWN_MODEL')`. ## Catalog resolution -A profile's `models` list *replaces* the route's installed catalog rather than extending it; omitting it (or leaving it empty) serves that catalog unchanged. Each entry defaults its unset fields from the installed model of the same `id`, so narrowing a catalog route to two models, correcting one capacity, or adding a model newer than the installed catalog are all one-line edits. Only the fields the harness consumes are configurable — `id`, `name`, `contextWindow`, and `maxTokens`. Pricing and input modalities have no harness consumer and ride the installed entry or are absent. Reasoning is not per-model configurable at all: a bare capability flag would make pi-ai advertise effort levels with no `thinkingLevelMap` to spell them, and no listing endpoint reports a model's reasoning protocol, so reasoning rides the installed catalog entry or is absent. +A profile's `models` list *replaces* the route's installed catalog rather than extending it; omitting it (or leaving it empty) serves that catalog unchanged. Each entry defaults its unset fields from the installed model of the same `id`, so narrowing a catalog route to two models, correcting one capacity, or adding a model newer than the installed catalog are all one-line edits — but declaring any `models` list means every model the route should keep serving must appear in it, an entry of nothing but `id` being enough. The configurable entry fields are `id`, `name`, `contextWindow`, `maxTokens`, `reasoningEfforts`, and `compat`. Pricing and input modalities have no harness consumer and ride the installed entry or are absent. + +### Per-model reasoning efforts + +`reasoningEfforts` declares a model's selectable thinking levels: each key is a level selectors offer, its value the spelling dispatch sends on the wire, so `high: high` passes the canonical name through while `max: ultra` renames it for a gateway with its own vocabulary. Keys come from pi-ai's level set (`off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`); a level not declared is not offered. Omitting the field keeps the installed catalog entry's capability (a hand-declared model has none and does not reason); `false` declares a non-reasoning model, which is how a profile strips reasoning from a catalog model its gateway cannot serve; an empty declaration is refused rather than guessing between those two meanings. + +The declaration translates to pi-ai's `Model.reasoning` + `thinkingLevelMap` with every level decided explicitly — undeclared levels are pinned unsupported rather than left to pi-ai's own defaulting, which is asymmetric (an absent key means "supported" for the five base levels but "unsupported" for `xhigh`/`max`) and which a profile author should not need to know. `off` is the one three-state key: left out, the model cannot stop thinking and selectors offer no Off; declared with no value (`off:`), Off is offered and selecting it sends nothing — for the `deepseek` dialect an explicit `thinking: {type: "disabled"}` — which also covers a request naming no effort at all; declared with a value (`off: none`), that value goes on the wire as the effort parameter. There is no spelling for restoring a catalog map key to "unset": the declaration is the whole offer, so restate the catalog levels you keep. + +### Reasoning-dispatch compat switches + +How a thinking level travels — `reasoning_effort` alone, DeepSeek's `thinking: {type}` plus effort, z.ai's `thinking` object, and so on — is pi-ai's `compat.thinkingFormat`, which pi-ai guesses from the endpoint URL; a private gateway's URL says nothing, so a DeepSeek-dialect gateway would be spoken to in the OpenAI dialect with no way to correct it. `compat.thinkingFormat` and `compat.supportsReasoningEffort` are therefore configurable on the route (its models' default) and per model (winning per field), resolving model → route → installed catalog entry → pi-ai's URL-derived guess; setting a route-level switch shadows the catalog entry's value for every model on the route, and there is no spelling for handing a field back to the catalog short of restating its value. `thinkingFormat` accepts pi-ai's dispatchable formats except the two `chat-template` variants, which need `chatTemplateKwargs` this configuration does not expose. Both switches exist only on `openai-completions` — the other protocols carry their reasoning shape in the protocol itself — so a model-level switch elsewhere fails resolution, a route-level one skips models of other protocols, and a route with no `openai-completions` model at all is refused. The rest of pi-ai's compat surface (`supportsStore`, `maxTokensField`, …) stays auto-detected and is deliberately not configurable here. A model neither the entry nor the installed catalog sizes takes the route's `defaultContextWindow` (262,144) and `defaultMaxTokens` (32,768), so a listing that discloses nothing but ids still yields a serviceable route. Both fallbacks are guesses by construction, which is why they are route fields a deployment whose gateway serves smaller models corrects once rather than constants buried in the adapter; the fallback sizes the model and never becomes a per-request cap. @@ -71,11 +94,11 @@ Credentials resolve per stream call: a non-empty literal `apiKey` wins, then `ap 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. -A model that carries reasoning metadata exposes pi-ai's ordered `getSupportedThinkingLevels(model)` result without filtering or normalization, including `off` and the model-specific availability of `xhigh` or `max`. The Harness exposes each canonical pi-ai level as an opaque ID; provider/model wire spellings remain inside pi-ai's `thinkingLevelMap`. +A model that carries reasoning metadata — from the installed catalog or from its entry's `reasoningEfforts` — exposes pi-ai's ordered `getSupportedThinkingLevels(model)` result without filtering or normalization, including `off` and the model-specific availability of `xhigh` or `max`. The Harness exposes each canonical pi-ai level as an opaque ID; provider/model wire spellings remain inside pi-ai's `thinkingLevelMap`. -A 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 a level absent from the exact model capability fails the REQUEST with `UNSUPPORTED_REASONING_EFFORT` before network I/O instead of being clamped. Describing a model never fails that way: the models under one provider disagree about which levels they accept, so `resolveModel` reports a profile level the exact model cannot take as no default at all rather than throwing. A throw there would take the whole provider out of every model catalog built over it — one mis-set profile field hiding even the models that do support the level — so a bad configuration surfaces where it is acted on, not where it is described. pi-ai's common stream options represent `off` by omitting `reasoning`. +A model **without** that metadata — a hand-declared one whose entry declares no `reasoningEfforts`, 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 a level absent from the exact model capability fails the REQUEST with `UNSUPPORTED_REASONING_EFFORT` before network I/O instead of being clamped. Describing a model never fails that way: the models under one provider disagree about which levels they accept, so `resolveModel` reports a profile level the exact model cannot take as no default at all rather than throwing. A throw there would take the whole provider out of every model catalog built over it — one mis-set profile field hiding even the models that do support the level — so a bad configuration surfaces where it is acted on, not where it is described. 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 `apiKey`, `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `compat`, `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`. diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index 71d45b590f..63464f80ee 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -42,18 +42,41 @@ apiKeyEnv: ACME_GATEWAY_API_KEY api: openai-completions baseURL: https://gateway.acme.example/v1 + # Reasoning dialect for an endpoint whose URL pi-ai cannot recognize. + compat: + thinkingFormat: deepseek models: - id: acme-large name: Acme Large contextWindow: 65536 maxTokens: 4096 + - id: acme-think + name: Acme Think + contextWindow: 262144 + maxTokens: 32768 + # key = selectable level, value = its wire spelling; only off may + # leave the value empty (supported, send nothing). + reasoningEfforts: + off: + high: high + max: ultra ``` 字典形状使重复路由无法表示,发布前的数组形状(每个 profile 携带 `provider` 字段)会加载失败并给出迁移指引。`providers` 也可以为空或整体省略:适配器将以**休眠**姿态挂载——零路由、模型选择器不多一条——一旦 `llm-pi-ai:` settings 分节提供了 profile 就即时注册路由,分节清空时随之撤销。无论是否休眠,插件都会在可配置提供方目录(`ctx.llm.listConfigurableProviders()`,settings 路径 `providers.`)中声明每个已安装 catalog 提供方,并与当前 profile 声明的每条路由取并集,因此配置界面既能在任何路由存在之前就提供完整 catalog,也能寻址一条手工声明的路由。每个条目都带上 `declared`:pi-ai 在这个键下是否什么都没有。它跟随已安装 catalog 而非设置文档,因为收窄一个内置提供方的模型同样会存下 profile,而那条路由仍然是 pi-ai 认识的——只有适配器分得清两者,所以由目录直接给出答案,而不是留给界面去猜。哪些适配器存在归组合面;哪些提供方在运行可以完全交给用户的设置文档。向 `ctx.llm` 注册具有原子性:如果与另一适配器已拥有的任何提供方路由冲突,插件会加载失败,不注册剩余路由。模型 id 不是生命周期配置;路由未配置的模型会在发起任何提供方请求前以 `LlmError('UNKNOWN_MODEL')` 失败。 ## Catalog 解析 -profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩充它;省略它(或留空)则原样服务该 catalog。每个条目都会从同 `id` 的已安装模型继承自身未设置的字段,因此把 catalog 路由收窄到两个模型、更正某个容量,或加入一个比已安装 catalog 更新的模型,都是一行编辑。只有 harness 会消费的字段可配置——`id`、`name`、`contextWindow` 与 `maxTokens`。定价与输入模态没有 harness 消费方,因此沿用已安装条目或直接缺席。推理则完全不按模型配置:一个孤立的能力布尔量会让 pi-ai 公布出没有 `thinkingLevelMap` 可供拼写的档位,而且没有任何列表端点会报告模型的推理协议,因此推理沿用已安装 catalog 条目或直接缺席。 +profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩充它;省略它(或留空)则原样服务该 catalog。每个条目都会从同 `id` 的已安装模型继承自身未设置的字段,因此把 catalog 路由收窄到两个模型、更正某个容量,或加入一个比已安装 catalog 更新的模型,都是一行编辑——但一旦声明了 `models` 列表,该路由要继续服务的每个模型就都必须出现在其中,条目哪怕只写一个 `id` 也足够。可配置的条目字段是 `id`、`name`、`contextWindow`、`maxTokens`、`reasoningEfforts` 与 `compat`。定价与输入模态没有 harness 消费方,因此沿用已安装条目或直接缺席。 + +### 按模型的推理档位 + +`reasoningEfforts` 声明模型可选的思考级别:每个键是选择器提供的一个档位,其值是分派在协议中发送的拼写,因此 `high: high` 原样透传规范名称,而 `max: ultra` 则为使用自有词汇的网关改名。键取自 pi-ai 的档位集合(`off`、`minimal`、`low`、`medium`、`high`、`xhigh`、`max`);未声明的档位不会被提供。省略该字段会保留已安装 catalog 条目的能力(手工声明的模型没有这份能力,也不推理);`false` 声明一个不具备推理能力的模型,profile 正是以此从其网关无法服务的 catalog 模型上剥除推理;空声明会被拒绝,而不是在这两种含义之间去猜。 + +该声明会转换为 pi-ai 的 `Model.reasoning` + `thinkingLevelMap`,其中每个档位都被显式决定——未声明的档位一律固定为不支持,而不是留给 pi-ai 自己的默认规则:那套规则并不对称(键缺席对五个基础档位意味着「支持」,对 `xhigh`/`max` 却意味着「不支持」),也本不该要求 profile 作者了解。`off` 是唯一的三态键:不写它,模型就无法停止思考,选择器也不提供 Off;声明而不给值(`off:`),则会提供 Off,选中它时什么也不发送——对 `deepseek` 方言则是一个显式的 `thinking: {type: "disabled"}`——这同时覆盖完全不点名任何档位的请求;声明并给值(`off: none`),该值就会作为档位参数在协议中发送。没有任何写法能把 catalog 映射中的键恢复为「未设置」:这份声明就是对外提供的全部,因此把你要保留的 catalog 档位重述出来。 + +### 推理分派的 compat 开关 + +思考级别如何在协议中传输——单独一个 `reasoning_effort`、DeepSeek 的 `thinking: {type}` 加上档位、z.ai 的 `thinking` 对象,诸如此类——就是 pi-ai 的 `compat.thinkingFormat`,pi-ai 会从端点 URL 猜测它;私有网关的 URL 什么也说明不了,于是说 DeepSeek 方言的网关只会收到 OpenAI 方言的请求,且无从更正。因此 `compat.thinkingFormat` 与 `compat.supportsReasoningEffort` 既可配置在路由上(作为其模型的默认值),也可按模型配置(逐字段胜出),解析顺序为模型 → 路由 → 已安装 catalog 条目 → pi-ai 按 URL 得出的猜测;设置路由级开关会为路由上的每个模型遮蔽 catalog 条目的值,而且除了重述其值,没有任何写法能把某个字段交还给 catalog。`thinkingFormat` 接受 pi-ai 可分派的各种格式,但不含两个 `chat-template` 变体:它们需要的 `chatTemplateKwargs` 本配置并不暴露。两个开关都只存在于 `openai-completions` 上——其余协议的推理形状由协议本身承载——因此在其他协议的模型上设置模型级开关会使解析失败,路由级开关会跳过其他协议的模型,而完全没有 `openai-completions` 模型的路由则会被拒绝。pi-ai compat 面的其余部分(`supportsStore`、`maxTokensField`……)保持自动检测,特意不在此处开放配置。 条目与已安装 catalog 都没有给出尺寸的模型,会采用该路由的 `defaultContextWindow`(262,144)与 `defaultMaxTokens`(32,768),因此一份只公布 id 的列表同样能产出可服务的路由。两个回退值本质上都是猜测,这正是它们作为路由字段、供网关服务更小模型的部署一次性更正的原因,而不是埋在适配器里的常量;回退值只用于给模型定尺寸,绝不会变成每请求上限。 @@ -71,11 +94,11 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩 适配器通过 `ctx.llm.listModels(provider)` 公开每条已配置路由的模型。这是从请求路径所用的同一个 pi-ai `Models` 集合读取的提供方无关 selector 元数据,因此发现不会创建第二个模型注册表。`ctx.llm.resolveModelInfo(provider, model)` 会执行一次精确 descriptor 查找,并返回其身份、上下文窗口、已配置输出上限和可选思考级别,让权威元数据保留在拥有路由的适配器上,而非消费方。模型**已配置**的 `maxTokens` 会成为 seam 的 `defaultMaxTokens`,因此未点名输出上限的请求会携带部署选定的那一个;而从已安装 catalog 继承来的值是模型的输出**能力**,绝不会自行变成请求默认值。 -携带推理元数据的模型会公开 pi-ai 有序的 `getSupportedThinkingLevels(model)` 结果,不经筛选或规范化,其中包括 `off`,以及模型对 `xhigh` 或 `max` 的特定支持。Harness 将每个规范 pi-ai 级别公开为不透明 ID;提供方/模型在协议格式中的表示仍保留在 pi-ai 的 `thinkingLevelMap` 中。 +携带推理元数据的模型——来自已安装 catalog,或来自其条目的 `reasoningEfforts`——会公开 pi-ai 有序的 `getSupportedThinkingLevels(model)` 结果,不经筛选或规范化,其中包括 `off`,以及模型对 `xhigh` 或 `max` 的特定支持。Harness 将每个规范 pi-ai 级别公开为不透明 ID;提供方/模型在协议格式中的表示仍保留在 pi-ai 的 `thinkingLevelMap` 中。 -**没有**这份元数据的模型——每一个手工声明的模型,以及 pi-ai 标记为不具备推理能力的 catalog 模型——完全不公开 `reasoning`。pi-ai 会把这类模型报告为只支持 `off` 一档,但 `off` 会被翻译成*省略* reasoning 选项,而那与「不点名任何档位」产出的请求逐字节相同:选它关不掉任何东西,于是自身默认就在思考的提供方,会在界面显示 `off` 被选中的同时继续思考。把该能力报告为不可用,界面就只剩提供方默认这一项,不会再出现自相矛盾的控件。配置 profile 的 `reasoning` 值(包括 `off`)在存在时是部署默认值;省略它会保留提供方默认值。每次请求的 `GenerateOptions.reasoningEffort` 优先;未出现在确切模型能力中的档位会让**请求**在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败,而不会被自动调整。**描述**一个模型则从不这样失败:同一提供方下各模型接受的档位并不一致,因此 `resolveModel` 对该模型拿不下的 profile 档位报告为「没有默认值」,而不是抛错。在那里抛错会让整个提供方从任何基于它构建的模型目录中消失——一个配错的 profile 字段连支持该档位的模型也一并藏起来——所以坏配置暴露在被执行处,而不是被描述处。pi-ai 的通用流选项通过省略 `reasoning` 表示 `off`。 +**没有**这份元数据的模型——条目未声明 `reasoningEfforts` 的手工声明模型,以及 pi-ai 标记为不具备推理能力的 catalog 模型——完全不公开 `reasoning`。pi-ai 会把这类模型报告为只支持 `off` 一档,但 `off` 会被翻译成*省略* reasoning 选项,而那与「不点名任何档位」产出的请求逐字节相同:选它关不掉任何东西,于是自身默认就在思考的提供方,会在界面显示 `off` 被选中的同时继续思考。把该能力报告为不可用,界面就只剩提供方默认这一项,不会再出现自相矛盾的控件。配置 profile 的 `reasoning` 值(包括 `off`)在存在时是部署默认值;省略它会保留提供方默认值。每次请求的 `GenerateOptions.reasoningEffort` 优先;未出现在确切模型能力中的档位会让**请求**在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败,而不会被自动调整。**描述**一个模型则从不这样失败:同一提供方下各模型接受的档位并不一致,因此 `resolveModel` 对该模型拿不下的 profile 档位报告为「没有默认值」,而不是抛错。在那里抛错会让整个提供方从任何基于它构建的模型目录中消失——一个配错的 profile 字段连支持该档位的模型也一并藏起来——所以坏配置暴露在被执行处,而不是被描述处。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 字段是 `apiKey`、`apiKeyEnv`、`displayName`、`api`、`baseURL`、`models`、`compat`、`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`。 diff --git a/packages/llm/llm-pi-ai/src/catalog.ts b/packages/llm/llm-pi-ai/src/catalog.ts index 173b84dd7d..e3c9207927 100644 --- a/packages/llm/llm-pi-ai/src/catalog.ts +++ b/packages/llm/llm-pi-ai/src/catalog.ts @@ -14,7 +14,15 @@ import { builtinProviders, getBuiltinModels, getBuiltinProviders } from '@earendil-works/pi-ai/providers/all' import type { BuiltinProvider } from '@earendil-works/pi-ai/providers/all' -import type { Api, Model, ModelCost, Provider } from '@earendil-works/pi-ai' +import type { + Api, + Model, + ModelCost, + ModelThinkingLevel, + OpenAICompletionsCompat, + Provider, + ThinkingLevelMap, +} from '@earendil-works/pi-ai' /** * Pricing for a model the installed catalog does not describe. The harness @@ -30,6 +38,58 @@ const NO_COST: ModelCost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } */ const TEXT_ONLY: Model['input'] = ['text'] +/** + * Every pi-ai thinking level, in pi-ai's canonical escalation order. The + * `Record` key type is a drift gate: a pi-ai upgrade that adds or removes a + * level fails compilation here naming the drifted key, instead of silently + * narrowing what a profile may declare. + */ +const THINKING_LEVEL_GATE: Record = { + off: true, + minimal: true, + low: true, + medium: true, + high: true, + xhigh: true, + max: true, +} + +/** Every pi-ai thinking level a profile may declare, in escalation order. */ +export const THINKING_LEVELS = Object.keys(THINKING_LEVEL_GATE) as readonly ModelThinkingLevel[] + +/** The `compat.thinkingFormat` spellings pi-ai accepts on an `openai-completions` model. */ +type PiThinkingFormat = NonNullable + +/** + * pi-ai thinking formats a profile cannot name: both drive the request through + * `chatTemplateKwargs`, which this configuration does not expose, so offering + * them would hand back a format with nothing to say. + */ +type WithheldThinkingFormat = 'chat-template' | 'qwen-chat-template' + +/** One reasoning-dispatch wire format a profile may name. */ +export type PiAiThinkingFormat = Exclude + +/** + * The nameable reasoning-dispatch formats, most-reached first. The `Record` + * key type is a drift gate: a pi-ai upgrade that adds a format (0.84 added + * `baseten`) fails compilation here until the format is classified as offered + * here or withheld above, so the offer never silently lags the upstream set. + */ +const THINKING_FORMAT_GATE: Record = { + 'openai': true, + 'deepseek': true, + 'openrouter': true, + 'together': true, + 'zai': true, + 'qwen': true, + 'string-thinking': true, + 'ant-ling': true, +} + +/** Reasoning-dispatch wire formats a profile may name, most-reached first. */ +export const SUPPORTED_THINKING_FORMATS = Object.keys(THINKING_FORMAT_GATE) as readonly PiAiThinkingFormat[] + let providerIndex: Map | undefined /** @@ -71,6 +131,32 @@ export function catalogModels(provider: string): Map> { return new Map(models.map(model => [model.id, model])) } +/** + * Selectable reasoning efforts for one model: each key is a level the model + * offers (and selectors show), and its value is the wire spelling dispatch + * sends for it. `off` alone may leave its value empty — "supported, send + * nothing" — because for most providers not thinking is the parameter's + * absence; every other declared level must name a wire value. A level absent + * from the dict is not offered. + */ +export type PiAiReasoningEfforts = Partial> + +/** + * Reasoning-dispatch compatibility switches, set on the route (its models' + * default) or per model (winning over the route). Only the switches pi-ai's + * reasoning dispatch reads are offered; the rest of pi-ai's compat surface + * keeps its baseURL-derived auto-detection. pi-ai types both fields only on + * `OpenAICompletionsCompat` — the other wire protocols carry their reasoning + * shape in the protocol itself — so resolution rejects a model-level switch + * anywhere else, while a route-level default skips past models it cannot fit. + */ +export interface PiAiCompatProfile { + /** Reasoning parameter shape the endpoint expects; absent keeps the catalog entry's, then pi-ai's baseURL-derived guess. */ + thinkingFormat?: PiAiThinkingFormat + /** Whether the endpoint accepts `reasoning_effort`; absent keeps the catalog entry's, then pi-ai's baseURL-derived guess. */ + supportsReasoningEffort?: boolean +} + /** One configured model entry: an id plus the catalog fields it overrides. */ export interface PiAiModelProfile { /** Model id sent to the provider and accepted by {@link GenerateOptions.model}. */ @@ -86,6 +172,16 @@ export interface PiAiModelProfile { * default on its own. */ maxTokens?: number + /** + * Selectable reasoning efforts. Absent inherits the installed catalog + * entry's capability (a hand-declared model has none and does not reason); + * `false` declares a non-reasoning model, which is how a profile strips + * reasoning from a catalog model its gateway cannot serve; a non-empty dict + * declares the offered levels and their wire spellings. + */ + reasoningEfforts?: false | PiAiReasoningEfforts + /** Reasoning-dispatch switches for this model, winning over the route's. */ + compat?: PiAiCompatProfile } /** The route-level facts model materialization reads. */ @@ -98,6 +194,8 @@ export interface RouteCatalogRequest { baseURL?: string /** Configured catalog; absent means the whole installed catalog for this route. */ models?: readonly PiAiModelProfile[] + /** Reasoning-dispatch switches for every `openai-completions` model on the route; entries override per field. */ + compat?: PiAiCompatProfile /** Context capacity for a model neither the entry nor the catalog sizes. */ defaultContextWindow: number /** Output capability for a model neither the entry nor the catalog sizes. */ @@ -123,6 +221,133 @@ function sharedCatalogApi(defaults: ReadonlyMap>): string | u return apis.size === 1 ? [...apis][0] : undefined } +/** The reasoning fields one materialized model carries. */ +interface ModelReasoning { + /** Whether the model reasons at all; `false` makes pi-ai ignore the map. */ + reasoning: boolean + /** The map dispatch reads; absent only when the installed entry's (or none) applies. */ + thinkingLevelMap?: ThinkingLevelMap +} + +/** + * Resolve one model's reasoning capability from its declared efforts. + * + * A declared dict translates to pi-ai's `thinkingLevelMap` with every level + * decided explicitly: declared levels carry their wire spelling, undeclared + * levels are pinned to `null` (unsupported). Pinning matters because pi-ai's + * own defaulting is asymmetric — an absent key means "supported" for the five + * base levels but "unsupported" for `xhigh`/`max` — and a profile author + * should not need to know that. A declared `off` with no value is the one + * exception: it stays absent from the map, which pi-ai reads as "supported, + * send nothing" — the correct dispatch where not thinking is the parameter's + * absence — while `off` with a value sends that value. + * @param provider - provider route key, for diagnostics. + * @param entry - the configured model entry. + * @param base - the installed catalog entry of the same id, when one exists. + * @returns the reasoning fields the materialized model carries. + */ +function resolveModelReasoning( + provider: string, + entry: PiAiModelProfile, + base: Model | undefined, +): ModelReasoning { + const efforts = entry.reasoningEfforts + if (efforts === undefined) { + // Reasoning rides the installed entry or is absent: a bare capability flag + // would make pi-ai advertise effort levels with no `thinkingLevelMap` to + // spell them, and no listing endpoint reports a model's reasoning + // protocol. The entry's map (when any) arrives through the `...base` + // spread in the model literal. + return { reasoning: base?.reasoning ?? false } + } + // The installed entry's map may ride along through `...base`; pi-ai never + // reads it on a non-reasoning model, so stripping it is not worth a field + // enumeration here. + if (efforts === false) return { reasoning: false } + // A YAML `reasoningEfforts:` left valueless arrives as null through the + // schema union — outside the field's declared type, hence the widening — + // while an explicit `{}` arrives as an empty dict. Both declare nothing, + // and neither is a spelling of "inherit" or "disable". + if ((efforts as unknown) === null || Object.keys(efforts).length === 0) { + invalid(provider, `model "${entry.id}" has an empty reasoningEfforts; declare the offered levels, set` + + ' false for a non-reasoning model, or omit the field to keep the installed catalog\'s capability') + } + const declared = THINKING_LEVELS.flatMap((level) => { + const wire = efforts[level] + return wire === undefined ? [] : [[level, wire] as const] + }) + for (const [level, wire] of declared) { + if (wire === null) { + if (level !== 'off') { + invalid(provider, `model "${entry.id}" reasoningEfforts.${level} needs the wire value dispatch` + + ' should send; only "off" may leave it empty') + } + } else if (wire.length === 0) { + invalid(provider, `model "${entry.id}" reasoningEfforts.${level} must not be an empty string`) + } + } + if (!declared.some(([level]) => level !== 'off')) { + invalid(provider, `model "${entry.id}" reasoningEfforts offers no level beyond "off"; declare a thinking` + + ' level, or set reasoningEfforts to false for a non-reasoning model') + } + const map: ThinkingLevelMap = {} + for (const level of THINKING_LEVELS) { + const wire = efforts[level] + if (wire === undefined) { + map[level] = null + } else if (wire !== null) { + map[level] = wire + } + } + return { reasoning: true, thinkingLevelMap: map } +} + +/** + * Resolve one model's compat block from the profile's reasoning switches. + * + * A model switch wins over the route switch; whatever neither sets keeps the + * installed entry's value, and a field no layer decides falls through to + * pi-ai's baseURL-derived detection. Only an `openai-completions` model takes + * the switches at all: a model-level switch on any other protocol fails + * resolution, while a route-level default skips past such models — the same + * posture as the route-level `reasoning` default, which also must not fail + * models it does not fit. + * @param provider - provider route key, for diagnostics. + * @param entry - the configured model entry. + * @param route - the route-level switches, when any. + * @param base - the installed catalog entry of the same id, when one exists. + * @param api - the model's resolved wire protocol. + * @returns a `compat` field to spread into the model, or nothing. + */ +function resolveModelCompat( + provider: string, + entry: PiAiModelProfile, + route: PiAiCompatProfile | undefined, + base: Model | undefined, + api: string, +): { compat: OpenAICompletionsCompat } | Record { + const thinkingFormat = entry.compat?.thinkingFormat ?? route?.thinkingFormat + const supportsReasoningEffort = entry.compat?.supportsReasoningEffort ?? route?.supportsReasoningEffort + if (thinkingFormat === undefined && supportsReasoningEffort === undefined) return {} + if (api !== 'openai-completions') { + if (entry.compat?.thinkingFormat !== undefined || entry.compat?.supportsReasoningEffort !== undefined) { + invalid(provider, `model "${entry.id}" sets compat reasoning switches, but its api is "${api}";` + + ' thinkingFormat and supportsReasoningEffort exist only on openai-completions') + } + return {} + } + // The installed entry's compat matches its own api, so on an + // openai-completions model it is the completions shape. + const inherited: OpenAICompletionsCompat | undefined = base?.compat + return { + compat: { + ...inherited, + ...thinkingFormat === undefined ? {} : { thinkingFormat }, + ...supportsReasoningEffort === undefined ? {} : { supportsReasoningEffort }, + }, + } +} + /** One route's materialized catalog, plus the request caps its profile chose. */ export interface RouteCatalog { /** The materialized models in configuration order. */ @@ -164,6 +389,8 @@ export function resolveRouteModels(request: RouteCatalogRequest): RouteCatalog { + ' must be listed in configuration') } const routeApi = sharedCatalogApi(defaults) + const routeCompatDefined = request.compat?.thinkingFormat !== undefined + || request.compat?.supportsReasoningEffort !== undefined const seen = new Set() const configuredMaxTokens = new Map() const models = entries.map((entry) => { @@ -209,15 +436,17 @@ export function resolveRouteModels(request: RouteCatalogRequest): RouteCatalog { api, provider, baseUrl, - // Reasoning rides the installed entry or is absent: a bare boolean would - // make pi-ai advertise effort levels with no `thinkingLevelMap` to spell - // them, and no listing endpoint reports a model's reasoning protocol. - reasoning: base?.reasoning ?? false, input: base?.input ?? TEXT_ONLY, cost: base?.cost ?? NO_COST, contextWindow, maxTokens, + ...resolveModelReasoning(provider, entry, base), + ...resolveModelCompat(provider, entry, request.compat, base, api), } }) + if (routeCompatDefined && !models.some(model => model.api === 'openai-completions')) { + invalid(provider, 'sets compat reasoning switches, but no model on the route speaks openai-completions;' + + ' thinkingFormat and supportsReasoningEffort exist only on that protocol') + } return { models, configuredMaxTokens } } diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index 7e8374ab9f..9d4cca089c 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -21,8 +21,8 @@ import type { CredentialRef } from '@deepseek-ai/dsh-credentials' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { normalizeApiKey, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm' import type { ResolvedRetryPolicy, RetryPolicyConfig } from '@deepseek-ai/dsh-llm' -import { resolveRouteModels } from './catalog.ts' -import type { PiAiModelProfile } from './catalog.ts' +import { resolveRouteModels, SUPPORTED_THINKING_FORMATS, THINKING_LEVELS } from './catalog.ts' +import type { PiAiCompatProfile, PiAiModelProfile, PiAiReasoningEfforts } from './catalog.ts' import { buildProvider, supportedProtocols } from './provider.ts' /** Default maximum idle interval while an adapter stream read is outstanding. */ @@ -34,7 +34,7 @@ export const DEFAULT_CONTEXT_WINDOW = 262_144 /** Output capability assumed for a model neither configuration nor the catalog sizes. */ export const DEFAULT_MAX_TOKENS = 32_768 -export type { PiAiModelProfile } from './catalog.ts' +export type { PiAiCompatProfile, PiAiModelProfile, PiAiReasoningEfforts, PiAiThinkingFormat } from './catalog.ts' /** Configuration for one pi-ai provider route; the `providers` dict key IS the route. */ export interface PiAiProviderProfile { @@ -62,6 +62,13 @@ export interface PiAiProviderProfile { * unset fields from the installed model of the same id. */ models?: PiAiModelProfile[] + /** + * Reasoning-dispatch switches for every `openai-completions` model on this + * route; each model's own `compat` overrides per field. What neither sets + * keeps the installed catalog entry's value, then pi-ai's baseURL-derived + * detection. + */ + compat?: PiAiCompatProfile /** * Context capacity for a model this route lists that neither the entry nor * the installed catalog sizes (default 262,144). A guess by construction, so @@ -139,11 +146,34 @@ const thinkingBudgets = z.object({ high: z.number(), }) +const compatProfile: z = z.object({ + thinkingFormat: z.union(SUPPORTED_THINKING_FORMATS), + supportsReasoningEffort: z.boolean(), +}) + +/** + * Keys are the offered levels, values their wire spellings. `z.const(null)` + * keeps a valueless key (`off:`) alive through validation — only resolution + * decides which levels may leave the value empty, so the diagnostic can name + * the route and model. The assertion narrows schemastery's `Dict`, which + * types every literal key as required; dict validation is per-present-key, so + * the runtime shape is the partial record. + */ +const reasoningEfforts = z.dict( + z.union([z.string(), z.const(null)]), + z.union(THINKING_LEVELS), +) as unknown as z + const modelProfile: z = z.object({ id: z.string().required(), name: z.string(), contextWindow: z.number().step(1).min(1), maxTokens: z.number().step(1).min(1), + // The union, not a bare dict: schemastery materializes an absent dict as + // `{}`, and absent must stay distinguishable — it means "inherit the + // installed catalog's capability", while `false` disables reasoning. + reasoningEfforts: z.union([z.const(false), reasoningEfforts]), + compat: compatProfile, }) const profile = z.object({ @@ -153,10 +183,11 @@ const profile = z.object({ api: z.union(supportedProtocols()), baseURL: z.string(), models: z.array(modelProfile), + compat: compatProfile, defaultContextWindow: z.number().step(1).min(1).default(DEFAULT_CONTEXT_WINDOW), defaultMaxTokens: z.number().step(1).min(1).default(DEFAULT_MAX_TOKENS), headers: z.dict(z.string()), - reasoning: z.union(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']), + reasoning: z.union(THINKING_LEVELS), thinkingBudgets, cacheRetention: z.union(['none', 'short', 'long']), transport: z.union(['sse', 'websocket', 'websocket-cached', 'auto']), @@ -260,6 +291,7 @@ export function resolveProfiles( ...source.api === undefined ? {} : { api: source.api }, ...source.baseURL === undefined ? {} : { baseURL: source.baseURL }, ...source.models === undefined ? {} : { models: source.models }, + ...source.compat === undefined ? {} : { compat: source.compat }, defaultContextWindow: source.defaultContextWindow ?? DEFAULT_CONTEXT_WINDOW, defaultMaxTokens: source.defaultMaxTokens ?? DEFAULT_MAX_TOKENS, }) diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index 2f98d7ac70..ea81f66fec 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -32,11 +32,24 @@ * apiKeyEnv: ACME_GATEWAY_API_KEY * api: openai-completions * baseURL: https://gateway.acme.example/v1 + * # Reasoning dialect for a URL pi-ai cannot recognize. + * compat: + * thinkingFormat: deepseek * models: * - id: acme-large * name: Acme Large * contextWindow: 65536 * maxTokens: 4096 + * - id: acme-think + * name: Acme Think + * contextWindow: 262144 + * maxTokens: 32768 + * # key = selectable level, value = wire spelling; only off may + * # leave the value empty (supported, send nothing). + * reasoningEfforts: + * off: + * high: high + * max: ultra * ``` * * @module @deepseek-ai/dsh-llm-pi-ai @@ -55,7 +68,14 @@ import { discoverModels } from './discovery.ts' export { PiAiAdapter } from './adapter.ts' export type { PiAiAdapterOptions } from './adapter.ts' export { Config } from './config.ts' -export type { PiAiModelProfile, PiAiProviderProfile, ResolvedPiAiProviderProfile } from './config.ts' +export type { + PiAiCompatProfile, + PiAiModelProfile, + PiAiProviderProfile, + PiAiReasoningEfforts, + PiAiThinkingFormat, + ResolvedPiAiProviderProfile, +} from './config.ts' export { supportedProtocols } from './provider.ts' export const name = 'llm-pi-ai' diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index 0184ca05cc..2d7798ff2e 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -400,6 +400,149 @@ describe('provider profile lifecycle', () => { .resolves.toMatchObject({ reasoning: { defaultEffort: ReasoningEffortId('off') } }) }) + it('serves declared reasoning efforts to selectors and honours the profile default', async () => { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(LlmPiAi, { + providers: { + 'acme-gateway': { + apiKey: 'gw-key', + api: 'openai-completions', + baseURL: 'https://acme.test/v1', + reasoning: 'high', + models: [{ + id: 'acme-think', + contextWindow: 65_536, + maxTokens: 4096, + reasoningEfforts: { off: null, low: 'low', high: 'high' }, + }], + }, + }, + }) + + // Declared levels reach the same seam catalog metadata does, so the + // effort picker works for a model pi-ai has never heard of. + await expect(ctx.llm.resolveModelInfo('acme-gateway', 'acme-think')).resolves.toMatchObject({ + reasoning: { + efforts: [ + { id: ReasoningEffortId('off'), name: 'Off' }, + { id: ReasoningEffortId('low'), name: 'Low' }, + { id: ReasoningEffortId('high'), name: 'High' }, + ], + defaultEffort: ReasoningEffortId('high'), + }, + }) + }) + + it('sends the declared wire spelling and refuses undeclared levels before network I/O', async () => { + const server = await mockServer([{ events: textEvents }]) + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(LlmPiAi, { + providers: { + 'acme-gateway': { + apiKey: 'gw-key', + api: 'openai-completions', + baseURL: `${server.url}/v1`, + models: [{ + id: 'acme-think', + contextWindow: 65_536, + maxTokens: 4096, + reasoningEfforts: { off: null, high: 'ultra' }, + }], + }, + }, + }) + + await assemble(ctx, { + provider: 'acme-gateway', + model: 'acme-think', + reasoningEffort: ReasoningEffortId('high'), + messages: [], + }) + // The declared value, not the canonical level name, goes on the wire. + expect(server.requests[0]).toMatchObject({ reasoning_effort: 'ultra' }) + + const undeclared = await assemble(ctx, { + provider: 'acme-gateway', + model: 'acme-think', + reasoningEffort: ReasoningEffortId('max'), + messages: [], + }) + expect(undeclared.finish).toMatchObject({ + kind: 'error', + failure: { code: 'UNSUPPORTED_REASONING_EFFORT' }, + }) + expect(server.requests).toHaveLength(1) + }) + + it('dispatches the compat-switched dialect on a declared route', async () => { + const server = await mockServer([{ events: textEvents }, { events: textEvents }]) + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(LlmPiAi, { + providers: { + 'acme-gateway': { + apiKey: 'gw-key', + api: 'openai-completions', + baseURL: `${server.url}/v1`, + // Without the switch pi-ai guesses the dialect from the endpoint + // URL, and a private gateway's URL says nothing. + compat: { thinkingFormat: 'deepseek' }, + models: [{ + id: 'acme-think', + contextWindow: 65_536, + maxTokens: 4096, + reasoningEfforts: { off: null, high: 'high' }, + }], + }, + }, + }) + const prompt = (effort: string): Promise => assemble(ctx, { + provider: 'acme-gateway', + model: 'acme-think', + reasoningEffort: ReasoningEffortId(effort), + messages: [], + }) + + await prompt('high') + expect(server.requests[0]).toMatchObject({ thinking: { type: 'enabled' }, reasoning_effort: 'high' }) + + await prompt('off') + expect(server.requests[1]).toMatchObject({ thinking: { type: 'disabled' } }) + expect(server.requests[1]).not.toHaveProperty('reasoning_effort') + }) + + it('holds back reasoning_effort when the endpoint cannot take it', async () => { + const server = await mockServer([{ events: textEvents }]) + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(LlmPiAi, { + providers: { + 'acme-gateway': { + apiKey: 'gw-key', + api: 'openai-completions', + baseURL: `${server.url}/v1`, + compat: { supportsReasoningEffort: false }, + models: [{ + id: 'acme-think', + contextWindow: 65_536, + maxTokens: 4096, + reasoningEfforts: { off: null, high: 'high' }, + }], + }, + }, + }) + + await assemble(ctx, { + provider: 'acme-gateway', + model: 'acme-think', + reasoningEffort: ReasoningEffortId('high'), + messages: [], + }) + expect(server.requests[0]).not.toHaveProperty('reasoning_effort') + }) + it('accepts absent credentials for pi-ai ambient authentication', async () => { vi.stubEnv('DEEPSEEK_API_KEY', 'ambient-key') const server = await mockServer([{ events: textEvents }]) diff --git a/packages/llm/llm-pi-ai/tests/catalog.spec.ts b/packages/llm/llm-pi-ai/tests/catalog.spec.ts index 2afbb87ec0..fbfcd653cf 100644 --- a/packages/llm/llm-pi-ai/tests/catalog.spec.ts +++ b/packages/llm/llm-pi-ai/tests/catalog.spec.ts @@ -10,8 +10,8 @@ import { settingsNamespace } from '@deepseek-ai/dsh-settings' import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' import { PiAiAdapter } from '@deepseek-ai/dsh-llm-pi-ai' import { getBuiltinModels } from '@earendil-works/pi-ai/providers/all' -import { createModels } from '@earendil-works/pi-ai' -import type { Api, Model, Provider } from '@earendil-works/pi-ai' +import { createModels, getSupportedThinkingLevels } from '@earendil-works/pi-ai' +import type { Api, Model, OpenAICompletionsCompat, Provider } from '@earendil-works/pi-ai' import { resolveProfiles } from '../src/config.ts' import { buildProvider, supportedProtocols } from '../src/provider.ts' import { assemble } from './assemble.ts' @@ -475,6 +475,177 @@ describe('catalog routes with per-model configuration', () => { }) }) +describe('per-model reasoning efforts', () => { + /** One hand-declared route holding exactly the given models. */ + function declared(models: LlmPiAi.PiAiModelProfile[]): Record { + return { 'acme-gateway': { api: 'openai-completions', baseURL: 'https://acme.test', models } } + } + + /** The first materialized model of one route, or throw. */ + function modelOf(providers: Record, route = 'acme-gateway'): Model { + const [model] = resolveProfiles(providers).get(route)?.piProvider.getModels() ?? [] + if (model === undefined) throw new Error(`route "${route}" resolved no models`) + return model + } + + it('declares selectable levels with their wire spellings on a hand-declared model', () => { + const model = modelOf(declared([{ + id: 'acme-think', + reasoningEfforts: { off: null, low: 'low', high: 'high', max: 'ultra' }, + }])) + + expect(model.reasoning).toBe(true) + // Undeclared levels are pinned null rather than left to pi-ai's own + // defaulting, which is asymmetric: an absent key means "supported" for the + // five base levels but "unsupported" for xhigh/max. A profile author + // should not need to know that. Declared `off` with no value stays absent + // from the map — supported, send nothing. + expect(model.thinkingLevelMap).toEqual({ + minimal: null, + medium: null, + xhigh: null, + low: 'low', + high: 'high', + max: 'ultra', + }) + expect(getSupportedThinkingLevels(model)).toEqual(['off', 'low', 'high', 'max']) + }) + + it('sends a declared off value on the wire instead of omitting the parameter', () => { + const model = modelOf(declared([{ id: 'm', reasoningEfforts: { off: 'none', high: 'high' } }])) + expect(model.thinkingLevelMap?.off).toBe('none') + expect(getSupportedThinkingLevels(model)).toEqual(['off', 'high']) + }) + + it('offers exactly the declared keys: leaving off out makes thinking mandatory', () => { + const model = modelOf(declared([{ id: 'm', reasoningEfforts: { high: 'high' } }])) + expect(getSupportedThinkingLevels(model)).toEqual(['high']) + }) + + it('narrows a catalog model’s levels in place', () => { + const [catalogModel] = getBuiltinModels('deepseek') + if (catalogModel === undefined) throw new Error('the installed catalog ships no deepseek model') + expect(getSupportedThinkingLevels(catalogModel as Model)).toEqual(['off', 'high', 'max']) + + const model = modelOf({ + deepseek: { models: [{ id: catalogModel.id, reasoningEfforts: { off: null, high: 'high' } }] }, + }, 'deepseek') + + expect(getSupportedThinkingLevels(model)).toEqual(['off', 'high']) + // Only the reasoning fields change; identity and capacities stay catalog. + expect(model.name).toBe(catalogModel.name) + expect(model.contextWindow).toBe(catalogModel.contextWindow) + }) + + it('strips reasoning from a catalog model with false', () => { + const [catalogModel] = getBuiltinModels('deepseek') + if (catalogModel === undefined) throw new Error('the installed catalog ships no deepseek model') + expect(catalogModel.reasoning).toBe(true) + + const model = modelOf({ deepseek: { models: [{ id: catalogModel.id, reasoningEfforts: false }] } }, 'deepseek') + + expect(model.reasoning).toBe(false) + expect(getSupportedThinkingLevels(model)).toEqual(['off']) + }) + + it('inherits the catalog capability when the field is absent', () => { + const [catalogModel] = getBuiltinModels('deepseek') + if (catalogModel === undefined) throw new Error('the installed catalog ships no deepseek model') + + const model = modelOf({ deepseek: { models: [{ id: catalogModel.id }] } }, 'deepseek') + + expect(model.reasoning).toBe(catalogModel.reasoning) + expect(model.thinkingLevelMap).toEqual(catalogModel.thinkingLevelMap) + }) + + it('rejects a declaration that offers nothing or spells a level it cannot send', () => { + const declare = (efforts: NonNullable): (() => unknown) => + () => resolveProfiles(declared([{ id: 'm', reasoningEfforts: efforts }])) + + expect(declare({})).toThrow(/empty reasoningEfforts/) + // A YAML `reasoningEfforts:` left valueless arrives as null through the + // schema union; it declares nothing and is not a spelling of "inherit". + expect(declare(null as never)).toThrow(/empty reasoningEfforts/) + expect(declare({ off: null })).toThrow(/offers no level beyond "off"/) + expect(declare({ off: 'none' })).toThrow(/offers no level beyond "off"/) + expect(declare({ high: null })).toThrow(/only "off" may leave it empty/) + expect(declare({ high: '' })).toThrow(/must not be an empty string/) + }) +}) + +describe('reasoning-dispatch compat switches', () => { + /** The materialized models of one route, keyed by id. */ + function modelsOf(providers: Record, route: string): Map> { + const models = resolveProfiles(providers).get(route)?.piProvider.getModels() ?? [] + return new Map(models.map(model => [model.id, model])) + } + + it('applies route switches to every openai-completions model, entries winning per field', () => { + const models = modelsOf({ + 'acme-gateway': { + api: 'openai-completions', + baseURL: 'https://acme.test', + compat: { thinkingFormat: 'deepseek' }, + models: [ + { id: 'dialect-default', reasoningEfforts: { off: null, high: 'high' } }, + { id: 'dialect-odd', compat: { thinkingFormat: 'openai', supportsReasoningEffort: false } }, + ], + }, + }, 'acme-gateway') + + expect(models.get('dialect-default')?.compat).toEqual({ thinkingFormat: 'deepseek' }) + expect(models.get('dialect-odd')?.compat).toEqual({ thinkingFormat: 'openai', supportsReasoningEffort: false }) + }) + + it('merges the switches over the catalog entry’s own compat instead of replacing it', () => { + const [catalogModel] = getBuiltinModels('deepseek') + if (catalogModel === undefined) throw new Error('the installed catalog ships no deepseek model') + const inherited = catalogModel.compat as OpenAICompletionsCompat + expect(inherited.requiresReasoningContentOnAssistantMessages).toBe(true) + + const models = modelsOf({ + deepseek: { models: [{ id: catalogModel.id, compat: { thinkingFormat: 'openai' } }] }, + }, 'deepseek') + + // The one switched field changes; the catalog's other quirks survive, + // because configuration has no way to restate them. + expect(models.get(catalogModel.id)?.compat).toEqual({ ...inherited, thinkingFormat: 'openai' }) + }) + + it('skips models of other protocols on a mixed route instead of failing them', () => { + // xai ships both completions and responses models, so a route-level switch + // must land on the former without invalidating the latter. + const catalog = getBuiltinModels('xai') as readonly Model[] + const completions = catalog.find(model => model.api === 'openai-completions') + const responses = catalog.find(model => model.api === 'openai-responses') + if (completions === undefined || responses === undefined) throw new Error('xai no longer ships a mixed catalog') + + const models = modelsOf({ + xai: { + compat: { supportsReasoningEffort: false }, + models: [{ id: completions.id }, { id: responses.id }], + }, + }, 'xai') + + expect((models.get(completions.id)?.compat as OpenAICompletionsCompat).supportsReasoningEffort).toBe(false) + expect(models.get(responses.id)?.compat).toEqual(responses.compat) + }) + + it('rejects a model-level switch on a protocol that has no such field', () => { + expect(() => resolveProfiles({ + anthropic: { + models: [{ id: 'claude-sonnet-4-5', compat: { thinkingFormat: 'openai' } }], + }, + })).toThrow(/exist only on openai-completions/) + }) + + it('rejects route switches no model on the route can take', () => { + expect(() => resolveProfiles({ + anthropic: { compat: { thinkingFormat: 'openai' } }, + })).toThrow(/no model on the route speaks openai-completions/) + }) +}) + describe('resolution snapshots', () => { it('finishes an in-flight request under the configuration it started with', async () => { const server = await mockServer([{ events: textEvents }]) diff --git a/packages/llm/llm-pi-ai/tests/config.spec.ts b/packages/llm/llm-pi-ai/tests/config.spec.ts index 90f8487ad8..5d041c1562 100644 --- a/packages/llm/llm-pi-ai/tests/config.spec.ts +++ b/packages/llm/llm-pi-ai/tests/config.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { resolveProfiles } from '../src/config.ts' +import { Config, resolveProfiles } from '../src/config.ts' describe('API key format', () => { it('trims a padded literal apiKey into the resolved profile', () => { @@ -22,3 +22,33 @@ describe('API key format', () => { .toThrow(/no HTTP header can carry/) }) }) + +describe('reasoning schema boundary', () => { + const configWith = (model: Record): (() => unknown) => + () => Config({ + providers: { + 'acme-gateway': { + api: 'openai-completions', + baseURL: 'https://acme.test', + models: [{ id: 'm', ...model }], + }, + }, + }) + + it('rejects a level pi-ai does not know at the write that produced it', () => { + expect(configWith({ reasoningEfforts: { ultra: 'x' } })).toThrow(/"off"/) + expect(configWith({ reasoningEfforts: { high: 42 } })).toThrow() + }) + + it('keeps false distinguishable from an absent declaration', () => { + type Materialized = { providers: Record } + const withFalse = configWith({ reasoningEfforts: false })() as Materialized + expect(withFalse.providers['acme-gateway']?.models?.[0]?.reasoningEfforts).toBe(false) + const absent = configWith({})() as Materialized + expect(absent.providers['acme-gateway']?.models?.[0]?.reasoningEfforts).toBeUndefined() + }) + + it('rejects a thinking format outside the offered set', () => { + expect(configWith({ compat: { thinkingFormat: 'quantum' } })).toThrow(/expected/) + }) +}) diff --git a/tsconfig.host.json b/tsconfig.host.json index 6884839536..0d87ec1fb7 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -10,6 +10,7 @@ "include": [ "apps/web/tests/scaffold.ts", "apps/web/tests/default-model.e2e.ts", + "apps/web/tests/declared-reasoning.e2e.ts", "apps/web/tests/support.ts", "apps/web/tests/scaffold-hermetic.e2e.ts", "apps/web/tests/core-web-profile.snapshot.ts", From cc0f6e11b9e108c42fd9619bfd115863e937ef5f Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 00:55:37 +0800 Subject: [PATCH 042/109] feat(tool-skill): teach the catalog about user-explicit skill injection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both catalog renderings now tell the model that a directly invoked skill arrives as an inline block to follow without re-loading it through the skill tool — the seam rule that keeps the user-explicit path and the model-autonomous path from double-injecting one skill. --- examples/acp-agent/tests/snapshots/skill-load/session.jsonl | 2 +- packages/skill/tool-skill/src/index.ts | 2 ++ packages/skill/tool-skill/tests/tool-skill.spec.ts | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/examples/acp-agent/tests/snapshots/skill-load/session.jsonl b/examples/acp-agent/tests/snapshots/skill-load/session.jsonl index f30dc715cf..ec369b492a 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/session.jsonl +++ b/examples/acp-agent/tests/snapshots/skill-load/session.jsonl @@ -5,7 +5,7 @@ {"type":"step/start","seq":3,"time":1785498773754,"data":{"turn":1,"step":1}} {"type":"user/message","seq":4,"time":1785498773754,"data":{"content":[{"type":"text","text":"Load the snapshot-skill skill with the skill tool, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"0ca31b92-27ac-451d-98d3-d1e5f605454b"},"surfaceOp":"append"} {"type":"user/message","seq":5,"time":1785498773755,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"3fc7e2f8-90fc-496c-b516-700cef1d86f1"},"surfaceOp":"append"} -{"type":"user/message","seq":6,"time":1785730426818,"data":{"content":[{"type":"text","text":"\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n\n- `model-only-skill`: Prove user-disabled skills remain available to the model.\n- `snapshot-skill`: Exercise project skill discovery and loading in snapshot tests.\n\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\n"}],"source":{"kind":"skill-catalog","form":"catalog","entries":[{"name":"model-only-skill","description":"Prove user-disabled skills remain available to the model."},{"name":"snapshot-skill","description":"Exercise project skill discovery and loading in snapshot tests."}]},"role":"user","id":"60880315-9799-44c8-8a99-e6fe9ee5bdc5"},"surfaceOp":"append"} +{"type":"user/message","seq":6,"time":1785730426818,"data":{"content":[{"type":"text","text":"\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n\n- `model-only-skill`: Prove user-disabled skills remain available to the model.\n- `snapshot-skill`: Exercise project skill discovery and loading in snapshot tests.\n\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\nA user may also invoke a skill directly; its block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill.\n"}],"source":{"kind":"skill-catalog","form":"catalog","entries":[{"name":"model-only-skill","description":"Prove user-disabled skills remain available to the model."},{"name":"snapshot-skill","description":"Exercise project skill discovery and loading in snapshot tests."}]},"role":"user","id":"60880315-9799-44c8-8a99-e6fe9ee5bdc5"},"surfaceOp":"append"} {"type":"session/title","seq":7,"time":1785730426818,"data":{"title":"Load the snapshot-skill skill with","messageSeqs":[4],"source":{"kind":"fallback"}}} {"type":"request/header","seq":8,"time":1785498773756,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":9,"time":1785730426819,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/packages/skill/tool-skill/src/index.ts b/packages/skill/tool-skill/src/index.ts index 19e154143d..aa9b509206 100644 --- a/packages/skill/tool-skill/src/index.ts +++ b/packages/skill/tool-skill/src/index.ts @@ -217,6 +217,7 @@ function renderCatalogMessage(entries: SkillCatalogSource['entries']): UserMessa '', '', "If the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.", + 'A user may also invoke a skill directly; its block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill.', '', ].join('\n'), }], @@ -235,6 +236,7 @@ function renderCatalogUpdate(entries: SkillCatalogSource['entries']): UserMessag ] : [ 'Use only names in this replacement catalog. If the user names a listed skill, or the task clearly matches its description, call the `skill` tool with the exact name before acting.', + 'A user may also invoke a skill directly; its block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill.', ] return createUserMessage({ content: [{ diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index 0755e398a0..9543c196af 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -287,6 +287,7 @@ describe('dsh-tool-skill', () => { '', '', "If the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.", + 'A user may also invoke a skill directly; its block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill.', '', ].join('\n'), }], From 756304322a22400e651f5be7ba1ccd294dd77ad7 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 00:57:41 +0800 Subject: [PATCH 043/109] feat(llm-pi-ai): modelOverrides reshapes catalog models without replacing the catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A route's modelOverrides dict customizes individual installed-catalog models — key = catalog model id, value = the same fields a models entry takes — while the rest of the catalog keeps serving, which a models list cannot express because declaring one replaces the served set. An override becomes the catalog entry's configuration and resolves through the existing entry path, so capacities, reasoningEfforts, compat, and request-default semantics are identical to a models entry's. Unlike Pi's config layer, which ignores unknown ids, every override that lands nowhere is refused at the write that produced it: beside a models list, on a hand-declared route, naming a model the catalog does not describe, or smuggling an id through the schema's unknown-key tolerance. --- packages/llm/llm-pi-ai/README.i18n.yaml | 4 +- packages/llm/llm-pi-ai/README.md | 13 +++- packages/llm/llm-pi-ai/README.zh.md | 13 +++- packages/llm/llm-pi-ai/src/catalog.ts | 39 ++++++++++- packages/llm/llm-pi-ai/src/config.ts | 30 ++++++++- packages/llm/llm-pi-ai/src/index.ts | 1 + packages/llm/llm-pi-ai/tests/catalog.spec.ts | 71 ++++++++++++++++++++ 7 files changed, 164 insertions(+), 7 deletions(-) diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 69efba1977..c8ae1899bd 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: 894aecc720f0a7616c0127d439b41129d94ef667 -README.zh.md: 63464f80ee3036ddec3fb6828ecccc68c5524478 +README.md: f208f553ab3a1f80c5b71f4792e5fc80459f9fa5 +README.zh.md: 24ae4b0e2021eeea373eacb8cc1dfc39063fee8b diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 894aecc720..f208f553ab 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -35,6 +35,15 @@ Configure credentials, the model catalog, and deployment-specific transport sett models: - id: claude-sonnet-4-5 contextWindow: 200000 + # Catalog route with one model reshaped in place; the rest of the + # catalog keeps serving (a models list would replace it instead). + deepseek: + apiKeyEnv: DEEPSEEK_API_KEY + modelOverrides: + deepseek-v4-pro: + reasoningEfforts: + off: + high: high # Hand-declared route: pi-ai ships nothing under this key, so the profile # supplies the whole provider. acme-gateway: @@ -68,6 +77,8 @@ The dict shape makes duplicate routes unrepresentable, and the pre-release array A profile's `models` list *replaces* the route's installed catalog rather than extending it; omitting it (or leaving it empty) serves that catalog unchanged. Each entry defaults its unset fields from the installed model of the same `id`, so narrowing a catalog route to two models, correcting one capacity, or adding a model newer than the installed catalog are all one-line edits — but declaring any `models` list means every model the route should keep serving must appear in it, an entry of nothing but `id` being enough. The configurable entry fields are `id`, `name`, `contextWindow`, `maxTokens`, `reasoningEfforts`, and `compat`. Pricing and input modalities have no harness consumer and ride the installed entry or are absent. +`modelOverrides` reshapes individual installed-catalog models without that cost: each key is a catalog model id, each value the same fields a `models` entry takes with the id living in the key, and the rest of the catalog keeps serving untouched — "correct one model, keep the other thirty-seven" as a three-line edit. An override becomes that catalog entry's configuration, so capacities, efforts, and compat resolve through the same path with the same diagnostics and the same request-default semantics as a `models` entry. Overrides are only meaningful on a catalog route serving its catalog: one set beside a `models` list (which already replaces the catalog), on a hand-declared route (whose models are fully spelled in `models`), or naming a model the catalog does not describe is refused rather than skipped, because a silently unchanged model is a typo someone would otherwise hunt for. + ### Per-model reasoning efforts `reasoningEfforts` declares a model's selectable thinking levels: each key is a level selectors offer, its value the spelling dispatch sends on the wire, so `high: high` passes the canonical name through while `max: ultra` renames it for a gateway with its own vocabulary. Keys come from pi-ai's level set (`off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`); a level not declared is not offered. Omitting the field keeps the installed catalog entry's capability (a hand-declared model has none and does not reason); `false` declares a non-reasoning model, which is how a profile strips reasoning from a catalog model its gateway cannot serve; an empty declaration is refused rather than guessing between those two meanings. @@ -98,7 +109,7 @@ A model that carries reasoning metadata — from the installed catalog or from i A model **without** that metadata — a hand-declared one whose entry declares no `reasoningEfforts`, 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 a level absent from the exact model capability fails the REQUEST with `UNSUPPORTED_REASONING_EFFORT` before network I/O instead of being clamped. Describing a model never fails that way: the models under one provider disagree about which levels they accept, so `resolveModel` reports a profile level the exact model cannot take as no default at all rather than throwing. A throw there would take the whole provider out of every model catalog built over it — one mis-set profile field hiding even the models that do support the level — so a bad configuration surfaces where it is acted on, not where it is described. pi-ai's common stream options represent `off` by omitting `reasoning`. -Supported profile fields are `apiKey`, `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `compat`, `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 `apiKey`, `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `modelOverrides`, `compat`, `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`. diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index 63464f80ee..24ae4b0e20 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -35,6 +35,15 @@ models: - id: claude-sonnet-4-5 contextWindow: 200000 + # Catalog route with one model reshaped in place; the rest of the + # catalog keeps serving (a models list would replace it instead). + deepseek: + apiKeyEnv: DEEPSEEK_API_KEY + modelOverrides: + deepseek-v4-pro: + reasoningEfforts: + off: + high: high # Hand-declared route: pi-ai ships nothing under this key, so the profile # supplies the whole provider. acme-gateway: @@ -68,6 +77,8 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩充它;省略它(或留空)则原样服务该 catalog。每个条目都会从同 `id` 的已安装模型继承自身未设置的字段,因此把 catalog 路由收窄到两个模型、更正某个容量,或加入一个比已安装 catalog 更新的模型,都是一行编辑——但一旦声明了 `models` 列表,该路由要继续服务的每个模型就都必须出现在其中,条目哪怕只写一个 `id` 也足够。可配置的条目字段是 `id`、`name`、`contextWindow`、`maxTokens`、`reasoningEfforts` 与 `compat`。定价与输入模态没有 harness 消费方,因此沿用已安装条目或直接缺席。 +`modelOverrides` 无需这份代价就能就地重塑单个已安装 catalog 模型:每个键是一个 catalog 模型 id,每个值可写 `models` 条目接受的同一批字段,只是 id 落在键上,而 catalog 的其余部分原样继续服务——「改一个模型、其余三十七个原样保留」只是一次三行编辑。一条覆盖会成为该 catalog 条目的配置,因此容量、档位与 compat 沿与 `models` 条目相同的路径解析,携带相同的诊断与相同的请求默认值语义。覆盖只在正服务自身 catalog 的 catalog 路由上才有意义:与 `models` 列表并存的一份(该列表本就替换了 catalog)、落在手工声明路由上的一份(其模型已在 `models` 中完整写出),或点名了 catalog 未描述模型的一份,都会被拒绝而非跳过,因为一个静默保持原样的模型,就是一个否则要有人费力追查的笔误。 + ### 按模型的推理档位 `reasoningEfforts` 声明模型可选的思考级别:每个键是选择器提供的一个档位,其值是分派在协议中发送的拼写,因此 `high: high` 原样透传规范名称,而 `max: ultra` 则为使用自有词汇的网关改名。键取自 pi-ai 的档位集合(`off`、`minimal`、`low`、`medium`、`high`、`xhigh`、`max`);未声明的档位不会被提供。省略该字段会保留已安装 catalog 条目的能力(手工声明的模型没有这份能力,也不推理);`false` 声明一个不具备推理能力的模型,profile 正是以此从其网关无法服务的 catalog 模型上剥除推理;空声明会被拒绝,而不是在这两种含义之间去猜。 @@ -98,7 +109,7 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩 **没有**这份元数据的模型——条目未声明 `reasoningEfforts` 的手工声明模型,以及 pi-ai 标记为不具备推理能力的 catalog 模型——完全不公开 `reasoning`。pi-ai 会把这类模型报告为只支持 `off` 一档,但 `off` 会被翻译成*省略* reasoning 选项,而那与「不点名任何档位」产出的请求逐字节相同:选它关不掉任何东西,于是自身默认就在思考的提供方,会在界面显示 `off` 被选中的同时继续思考。把该能力报告为不可用,界面就只剩提供方默认这一项,不会再出现自相矛盾的控件。配置 profile 的 `reasoning` 值(包括 `off`)在存在时是部署默认值;省略它会保留提供方默认值。每次请求的 `GenerateOptions.reasoningEffort` 优先;未出现在确切模型能力中的档位会让**请求**在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败,而不会被自动调整。**描述**一个模型则从不这样失败:同一提供方下各模型接受的档位并不一致,因此 `resolveModel` 对该模型拿不下的 profile 档位报告为「没有默认值」,而不是抛错。在那里抛错会让整个提供方从任何基于它构建的模型目录中消失——一个配错的 profile 字段连支持该档位的模型也一并藏起来——所以坏配置暴露在被执行处,而不是被描述处。pi-ai 的通用流选项通过省略 `reasoning` 表示 `off`。 -受支持的 profile 字段是 `apiKey`、`apiKeyEnv`、`displayName`、`api`、`baseURL`、`models`、`compat`、`defaultContextWindow`、`defaultMaxTokens`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs` 和 `retryPolicy`。每个 profile 的可选重试策略都会与该提供方路由一同捕获;省略时使用有界的常规默认值。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。若已配置标头中有同名项,则以 Harness 应用归因为准。 +受支持的 profile 字段是 `apiKey`、`apiKeyEnv`、`displayName`、`api`、`baseURL`、`models`、`modelOverrides`、`compat`、`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`。 diff --git a/packages/llm/llm-pi-ai/src/catalog.ts b/packages/llm/llm-pi-ai/src/catalog.ts index e3c9207927..3285d1595a 100644 --- a/packages/llm/llm-pi-ai/src/catalog.ts +++ b/packages/llm/llm-pi-ai/src/catalog.ts @@ -184,6 +184,15 @@ export interface PiAiModelProfile { compat?: PiAiCompatProfile } +/** + * Customization of one installed catalog model, keyed by its id in the + * route's `modelOverrides` dict — the same fields a `models` entry may set, + * with the id living in the key. Unlike a `models` list, overrides leave the + * rest of the catalog serving untouched, which is what makes "correct one + * model, keep the other thirty-seven" a three-line edit. + */ +export type PiAiModelOverride = Omit + /** The route-level facts model materialization reads. */ export interface RouteCatalogRequest { /** Provider route key, stamped onto every materialized model. */ @@ -194,6 +203,8 @@ export interface RouteCatalogRequest { baseURL?: string /** Configured catalog; absent means the whole installed catalog for this route. */ models?: readonly PiAiModelProfile[] + /** Installed-catalog customizations by model id; only meaningful while `models` is absent. */ + modelOverrides?: Readonly> /** Reasoning-dispatch switches for every `openai-completions` model on the route; entries override per field. */ compat?: PiAiCompatProfile /** Context capacity for a model neither the entry nor the catalog sizes. */ @@ -381,9 +392,35 @@ export function resolveRouteModels(request: RouteCatalogRequest): RouteCatalog { // schema materializes `[]` for the absent case, and an empty catalog could // serve no request anyway, so both mean "serve the installed catalog". const configured = request.models ?? [] + const overrides = request.modelOverrides ?? {} + // Every miss is refused, never skipped: an override that lands nowhere is a + // typo someone would otherwise hunt for in a silently unchanged model. + for (const [id, override] of Object.entries(overrides)) { + if (id.length === 0) invalid(provider, 'has a modelOverrides entry with an empty model id') + if (defaults.size === 0) { + invalid(provider, `sets modelOverrides for "${id}", but the installed catalog does not describe this route;` + + ' a declared route spells every model out in its models list') + } + if (configured.length > 0) { + invalid(provider, `sets modelOverrides for "${id}" beside a models list; models already replaces the served` + + ' catalog, so declare the fields on its entries') + } + if (!defaults.has(id)) { + invalid(provider, `modelOverrides names "${id}", which the installed catalog does not describe`) + } + // The id lives in the dict key; a value carrying its own would quietly + // rename the model it meant to customize. The static shape already omits + // it — this guards the schema boundary, which passes unknown keys through. + if ('id' in override) { + invalid(provider, `modelOverrides entry "${id}" sets "id", which is the dict key`) + } + } + // An override becomes the catalog entry's configuration, so everything a + // models entry may declare — capacities, efforts, compat — resolves through + // the same path with the same diagnostics and request-default semantics. const entries: readonly PiAiModelProfile[] = configured.length > 0 ? configured - : [...defaults.values()].map(model => ({ id: model.id })) + : [...defaults.values()].map(model => ({ id: model.id, ...overrides[model.id] })) if (entries.length === 0) { invalid(provider, 'resolves no models; the installed catalog does not describe this route, so its models' + ' must be listed in configuration') diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index 9d4cca089c..d93f68bcd9 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -22,7 +22,7 @@ import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { normalizeApiKey, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm' import type { ResolvedRetryPolicy, RetryPolicyConfig } from '@deepseek-ai/dsh-llm' import { resolveRouteModels, SUPPORTED_THINKING_FORMATS, THINKING_LEVELS } from './catalog.ts' -import type { PiAiCompatProfile, PiAiModelProfile, PiAiReasoningEfforts } from './catalog.ts' +import type { PiAiCompatProfile, PiAiModelOverride, PiAiModelProfile, PiAiReasoningEfforts } from './catalog.ts' import { buildProvider, supportedProtocols } from './provider.ts' /** Default maximum idle interval while an adapter stream read is outstanding. */ @@ -34,7 +34,13 @@ export const DEFAULT_CONTEXT_WINDOW = 262_144 /** Output capability assumed for a model neither configuration nor the catalog sizes. */ export const DEFAULT_MAX_TOKENS = 32_768 -export type { PiAiCompatProfile, PiAiModelProfile, PiAiReasoningEfforts, PiAiThinkingFormat } from './catalog.ts' +export type { + PiAiCompatProfile, + PiAiModelOverride, + PiAiModelProfile, + PiAiReasoningEfforts, + PiAiThinkingFormat, +} from './catalog.ts' /** Configuration for one pi-ai provider route; the `providers` dict key IS the route. */ export interface PiAiProviderProfile { @@ -62,6 +68,15 @@ export interface PiAiProviderProfile { * unset fields from the installed model of the same id. */ models?: PiAiModelProfile[] + /** + * Installed-catalog customizations by model id: each entry reshapes that + * one model with the same fields a {@link models} entry takes, while the + * rest of the catalog keeps serving untouched. Only meaningful on a catalog + * route with no `models` list — `models` already replaces the catalog, so + * an override beside it, on a route the catalog does not ship, or naming a + * model the catalog does not describe is refused rather than skipped. + */ + modelOverrides?: Record /** * Reasoning-dispatch switches for every `openai-completions` model on this * route; each model's own `compat` overrides per field. What neither sets @@ -176,6 +191,15 @@ const modelProfile: z = z.object({ compat: compatProfile, }) +/** A {@link modelProfile} whose id lives in the `modelOverrides` dict key. */ +const modelOverride: z = z.object({ + name: z.string(), + contextWindow: z.number().step(1).min(1), + maxTokens: z.number().step(1).min(1), + reasoningEfforts: z.union([z.const(false), reasoningEfforts]), + compat: compatProfile, +}) + const profile = z.object({ apiKey: z.string().role('secret'), apiKeyEnv: z.string().role('credential-ref'), @@ -183,6 +207,7 @@ const profile = z.object({ api: z.union(supportedProtocols()), baseURL: z.string(), models: z.array(modelProfile), + modelOverrides: z.dict(modelOverride), compat: compatProfile, defaultContextWindow: z.number().step(1).min(1).default(DEFAULT_CONTEXT_WINDOW), defaultMaxTokens: z.number().step(1).min(1).default(DEFAULT_MAX_TOKENS), @@ -291,6 +316,7 @@ export function resolveProfiles( ...source.api === undefined ? {} : { api: source.api }, ...source.baseURL === undefined ? {} : { baseURL: source.baseURL }, ...source.models === undefined ? {} : { models: source.models }, + ...source.modelOverrides === undefined ? {} : { modelOverrides: source.modelOverrides }, ...source.compat === undefined ? {} : { compat: source.compat }, defaultContextWindow: source.defaultContextWindow ?? DEFAULT_CONTEXT_WINDOW, defaultMaxTokens: source.defaultMaxTokens ?? DEFAULT_MAX_TOKENS, diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index ea81f66fec..e00b9f3c2a 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -70,6 +70,7 @@ export type { PiAiAdapterOptions } from './adapter.ts' export { Config } from './config.ts' export type { PiAiCompatProfile, + PiAiModelOverride, PiAiModelProfile, PiAiProviderProfile, PiAiReasoningEfforts, diff --git a/packages/llm/llm-pi-ai/tests/catalog.spec.ts b/packages/llm/llm-pi-ai/tests/catalog.spec.ts index fbfcd653cf..de806558c5 100644 --- a/packages/llm/llm-pi-ai/tests/catalog.spec.ts +++ b/packages/llm/llm-pi-ai/tests/catalog.spec.ts @@ -573,6 +573,77 @@ describe('per-model reasoning efforts', () => { }) }) +describe('modelOverrides', () => { + const deepseekModel = (): Model => { + const [model] = getBuiltinModels('deepseek') + if (model === undefined) throw new Error('the installed catalog ships no deepseek model') + return model + } + + it('reshapes one catalog model while the rest of the catalog keeps serving', () => { + const catalogSize = getBuiltinModels('deepseek').length + const target = deepseekModel() + const resolved = resolveProfiles({ + deepseek: { + modelOverrides: { + [target.id]: { + name: 'DeepSeek (proxied)', + maxTokens: 4096, + reasoningEfforts: { off: null, high: 'high' }, + }, + }, + }, + }) + const models = resolved.get('deepseek')?.piProvider.getModels() ?? [] + const reshaped = models.find(model => model.id === target.id) + if (reshaped === undefined) throw new Error('the overridden model vanished from the route') + + // The whole catalog still serves — that is the difference from `models`, + // which replaces it. + expect(models).toHaveLength(catalogSize) + expect(reshaped.name).toBe('DeepSeek (proxied)') + expect(getSupportedThinkingLevels(reshaped)).toEqual(['off', 'high']) + // An override's cap is explicit configuration, so it becomes the request + // default exactly as a models entry's would. + expect(resolved.get('deepseek')?.configuredMaxTokens.get(target.id)).toBe(4096) + // A sibling the overrides do not name is byte-identical to the catalog. + const sibling = models.find(model => model.id !== target.id) + expect(sibling?.maxTokens).toBe(getBuiltinModels('deepseek').find(model => model.id === sibling?.id)?.maxTokens) + }) + + it('refuses every override that lands nowhere instead of skipping it', () => { + expect(() => resolveProfiles({ + deepseek: { modelOverrides: { 'no-such-model': { name: 'ghost' } } }, + })).toThrow(/which the installed catalog does not describe/) + expect(() => resolveProfiles({ + 'acme-gateway': { + api: 'openai-completions', + baseURL: 'https://acme.test', + models: [{ id: 'm' }], + modelOverrides: { m: { name: 'renamed' } }, + }, + })).toThrow(/a declared route spells every model out/) + const declaredOnly = deepseekModel() + expect(() => resolveProfiles({ + deepseek: { + models: [{ id: declaredOnly.id }], + modelOverrides: { [declaredOnly.id]: { name: 'renamed' } }, + }, + })).toThrow(/models already replaces the served catalog/) + expect(() => resolveProfiles({ + deepseek: { modelOverrides: { '': { name: 'nameless' } } }, + })).toThrow(/empty model id/) + // The dict key is the id; a value smuggling its own would quietly rename + // the model it meant to customize. The schema passes unknown keys + // through, so resolution is the boundary that refuses it — the variable + // indirection mirrors that boundary by sidestepping the literal check. + const smuggled = { name: 'x', id: 'other' } + expect(() => resolveProfiles({ + deepseek: { modelOverrides: { [deepseekModel().id]: smuggled } }, + })).toThrow(/sets "id", which is the dict key/) + }) +}) + describe('reasoning-dispatch compat switches', () => { /** The materialized models of one route, keyed by id. */ function modelsOf(providers: Record, route: string): Map> { From 56e9e617498a5ac9bc5a8c4b1878c776bb2c299a Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 00:59:55 +0800 Subject: [PATCH 044/109] feat(ui-skill): claim slash skill references into skill.invoke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A menu pick or an entered /name line now claims the composer into an args-tolerant skill.invoke transaction instead of shipping the literal text and hoping the model loads the skill. This gives every user-invocable skill a deterministic entry point — including disable-model-invocation skills the catalog never shows the model (issue #1470). Candidates carry a user-only hint, and the unreached legacy reference codec is removed (decision 21 removal cut). --- packages/client/connection/tests/fake-api.ts | 4 + packages/client/runtime/tests/fake-api.ts | 4 + packages/client/ui-skill/src/client/index.ts | 71 ++++++++++---- .../client/ui-skill/src/client/locales.ts | 2 + .../ui-skill/tests/browser-plugin.spec.ts | 92 +++++++++++++++---- 5 files changed, 139 insertions(+), 34 deletions(-) diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index cc1062843e..bd8efaf6a4 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -163,6 +163,9 @@ export class FakeApiClient implements IApiClient { onSkillList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ skills: [] })) + onSkillInvoke: (payload: unknown) => Promise> + = () => Promise.resolve(ok({ accepted: true as const })) + readonly commands: IApiClient['commands'] = { list: (payload: unknown) => this.record('command.list', payload, this.onCommandList(payload)), execute: (payload: unknown) => this.record('command.execute', payload, this.onCommandExecute(payload)), @@ -170,6 +173,7 @@ export class FakeApiClient implements IApiClient { readonly skills: IApiClient['skills'] = { list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)), + invoke: (payload: unknown) => this.record('skill.invoke', payload, this.onSkillInvoke(payload)), } readonly goals: IApiClient['goals'] = { diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index b6f2884837..def535a59a 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -198,6 +198,9 @@ export class FakeApiClient implements IApiClient { onSkillList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ skills: [] })) + onSkillInvoke: (payload: unknown) => Promise> + = () => Promise.resolve(ok({ accepted: true as const })) + readonly commands: IApiClient['commands'] = { list: (payload: unknown) => this.record('command.list', payload, this.onCommandList(payload)), execute: (payload: unknown) => this.record('command.execute', payload, this.onCommandExecute(payload)), @@ -205,6 +208,7 @@ export class FakeApiClient implements IApiClient { readonly skills: IApiClient['skills'] = { list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)), + invoke: (payload: unknown) => this.record('skill.invoke', payload, this.onSkillInvoke(payload)), } readonly goals: IApiClient['goals'] = { diff --git a/packages/client/ui-skill/src/client/index.ts b/packages/client/ui-skill/src/client/index.ts index 9631125801..7d859bf2fb 100644 --- a/packages/client/ui-skill/src/client/index.ts +++ b/packages/client/ui-skill/src/client/index.ts @@ -2,13 +2,15 @@ * Skill reference plugin, browser half: registers the '/' skill source — * candidates from the skill.list RPC addressed by the per-call session * projection's sessionId (sessions are always agent-backed; the host - * resolves cwd from the session header), pick inserts the literal `/name ` - * text (decision 21: the draft carries plain text, chip visuals are derived - * by scanning against the source lexicon, and the prompt ships the same - * literal — no `` tag). The RPC rides the plugin's root-context - * connection captured at registration — the source never reads services off - * a per-call argument. No adjudication hooks: skill references ride - * ordinary prompts and never enter command adjudication. + * resolves cwd from the session header). A menu pick or an entered `/name + * [args]` line claims into a skill.invoke transaction: the host renders the + * skill body and injects it as a user message, so invocation is + * deterministic for every user-invocable skill — including + * `disable-model-invocation` skills the model-side catalog never lists + * (issue #1470). The RPC rides the plugin's root-context connection + * captured at registration — the source never reads services off a per-call + * argument. Draft chip visuals still derive from the lexicon scan; the + * legacy `` reference codec is gone (decision 21 removal cut). * * Catalog fetches are cached per session (the small twin of the ui-command * directory): the per-keystroke candidates re-poll filters a settled @@ -25,7 +27,7 @@ */ import type { ConnectionHandle, SessionId, SkillEntry } from '@deepseek-ai/dsh-client-connection/client' import type { ClientContext, ISessions } from '@deepseek-ai/dsh-client-runtime/client' -import type { SlashServiceContract, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client' +import type { PickOutcome, SlashServiceContract, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' import { SkillRow } from './SkillRow.tsx' @@ -119,6 +121,30 @@ export function apply(ctx: ClientContext): void { for (const key of [...fetches.keys()]) invalidate(key) } + /** User-only marker in the active language (the menu hint is plain text, resolved at candidate time). */ + const userOnlyHint = (): string => ctx.locale.getSnapshot().active === 'zh' ? zh['menu.userOnly'] : en['menu.userOnly'] + + /** + * Args-tolerant claim for one skill: token `/name ` plus the skill.invoke + * transaction. Blank args stay off the wire; an RPC refusal folds into the + * composer's error outcome (transport failures throw). + */ + const invokeClaim = (session: { readonly sessionId: SessionId }, name: string): PickOutcome => ({ + claim: { + token: `/${name} `, + submit: async (args) => { + const trimmed = args.trim() + const { result } = await skills.invoke({ + sessionId: session.sessionId, + name, + ...trimmed === '' ? {} : { text: trimmed }, + }) + if (!result.ok) return { kind: 'error', text: `${result.error.code}: ${result.error.message}` } + return { kind: 'success' } + }, + }, + }) + const source: SlashSource = { trigger: '/', name: 'skill', @@ -129,7 +155,11 @@ export function apply(ctx: ClientContext): void { if (signal.aborted) return [] return skills .filter(skill => skill.name.startsWith(query)) - .map(skill => ({ name: skill.name, description: skill.description })) + .map(skill => ({ + name: skill.name, + description: skill.description, + ...skill.modelInvocable ? {} : { hint: userOnlyHint() }, + })) }, warm(session) { // Fire-and-forget scope-birth prewarm; the shared fetch reports @@ -149,16 +179,21 @@ export function apply(ctx: ClientContext): void { if (listeners.size === 0) lexiconListeners.delete(key) } }, - onPick({ candidate }) { - // Decision 21: plain-text reference — the literal lands in the draft - // and ships to the model verbatim (trailing space closes the token). - // Legacy path (decision 21), retained for the removal cut, no longer reached: - // return { insert: { source: 'skill', ref: candidate.name, label: candidate.name, clipboardText: `/${candidate.name}` } } - return { text: `/${candidate.name} ` } + onPick({ candidate, session }) { + return invokeClaim(session, candidate.name) }, - codec: { - clipboardText: ref => `/${ref}`, - serialize: ref => Promise.resolve(`${ref}`), + async matchEnter(session, line, signal) { + const trimmed = line.trim() + if (!trimmed.startsWith('/')) return undefined + const ws = trimmed.search(/\s/) + const name = (ws === -1 ? trimmed : trimmed.slice(0, ws)).slice(1) + if (name === '') return undefined + // Strong-wait the catalog: an unknown name stays a plain prompt (the + // default sink), never a swallowed line. + const catalog = await fetchCatalog(session.sessionId) + if (signal.aborted) return undefined + if (!catalog.some(skill => skill.name === name)) return undefined + return invokeClaim(session, name) }, } const slash = ctx.get('slash') as SlashServiceContract diff --git a/packages/client/ui-skill/src/client/locales.ts b/packages/client/ui-skill/src/client/locales.ts index 53746397bc..40ef78dea5 100644 --- a/packages/client/ui-skill/src/client/locales.ts +++ b/packages/client/ui-skill/src/client/locales.ts @@ -9,6 +9,7 @@ export const zh = { 'row.failed': 'skill 加载失败', 'row.stopped': 'skill 加载已中止', 'row.instructions': '说明', + 'menu.userOnly': '仅用户', } satisfies Record /** The skill namespace key union. */ @@ -20,4 +21,5 @@ export const en = { 'row.failed': 'Skill load failed', 'row.stopped': 'Skill load stopped', 'row.instructions': 'Instructions', + 'menu.userOnly': 'user-only', } satisfies Record diff --git a/packages/client/ui-skill/tests/browser-plugin.spec.ts b/packages/client/ui-skill/tests/browser-plugin.spec.ts index 9b047a3713..e38adf7686 100644 --- a/packages/client/ui-skill/tests/browser-plugin.spec.ts +++ b/packages/client/ui-skill/tests/browser-plugin.spec.ts @@ -20,11 +20,15 @@ import type { ClientSessionContext, SlashSource } from '@deepseek-ai/dsh-client- import { apply, inject } from '../src/client/index.ts' import { SkillRow as SkillToolRow } from '../src/client/SkillRow.tsx' -type SkillRow = { name: string; description: string; whenToUse?: string } +type SkillRow = { name: string; description: string; whenToUse?: string; modelInvocable?: boolean } type ListResult = | { ok: true; value: { skills: SkillRow[] } } | { ok: false; error: { code: string; message: string; details: object } } type ListFn = (payload: object, signal?: AbortSignal) => Promise<{ result: ListResult }> +type InvokeResult = + | { ok: true; value: { accepted: true } } + | { ok: false; error: { code: string; message: string; details: object } } +type InvokeFn = (payload: object) => Promise<{ result: InvokeResult }> interface PresentationCapture { slots: SlotsService @@ -49,16 +53,18 @@ function providePresentation(ctx: Context): PresentationCapture { capture.dictionaries.push({ namespace, dictionaries }) return () => { capture.localeDisposed = true } }, + getSnapshot: () => ({ active: 'zh', locales: ['zh', 'en'], revision: 0 }), }) return capture } /** Boot the plugin over fake slash/connection faces; returns the captured source and its ctx. */ -async function bench(list: ListFn, addressed?: SessionId) { +async function bench(list: ListFn, addressed?: SessionId, invoke?: InvokeFn) { const ctx = new Context() let captured: SlashSource | undefined ctx.provide('slash', { registerSource: (src: SlashSource) => { captured = src; return () => {} } }) - ctx.provide('connection', { api: { skills: { list } } }) + const defaultInvoke: InvokeFn = () => Promise.resolve({ result: { ok: true as const, value: { accepted: true as const } } }) + ctx.provide('connection', { api: { skills: { list, invoke: invoke ?? defaultInvoke } } }) ctx.provide('sessions', { subagentAddress: (id: SessionId) => id === addressed ? { parentSessionId: sid('parent'), childSessionId: id, mode: 'continuable' as const } @@ -70,9 +76,9 @@ async function bench(list: ListFn, addressed?: SessionId) { } const CATALOG: SkillRow[] = [ - { name: 'commit-helper', description: 'commit flow' }, - { name: 'code-review', description: 'review flow', whenToUse: 'reviews' }, - { name: 'deploy', description: 'deploy flow' }, + { name: 'commit-helper', description: 'commit flow', modelInvocable: true }, + { name: 'code-review', description: 'review flow', whenToUse: 'reviews', modelInvocable: true }, + { name: 'deploy', description: 'deploy flow', modelInvocable: true }, ] const listOk = (skills: SkillRow[]): ListFn => () => Promise.resolve({ result: { ok: true as const, value: { skills } } }) @@ -117,12 +123,14 @@ describe('apply', () => { 'row.failed': 'skill 加载失败', 'row.stopped': 'skill 加载已中止', 'row.instructions': '说明', + 'menu.userOnly': '仅用户', }, en: { 'row.running': 'Loading skill', 'row.failed': 'Skill load failed', 'row.stopped': 'Skill load stopped', 'row.instructions': 'Instructions', + 'menu.userOnly': 'user-only', }, }, }]) @@ -313,9 +321,10 @@ describe('lexicon', () => { }) }) -describe('pick and codec', () => { - it('onPick returns the literal /name text with a closing space (decision 21)', async () => { - const { source } = await bench(listOk(CATALOG)) +describe('pick claims into skill.invoke', () => { + it('onPick returns an args-tolerant claim whose submit invokes the skill', async () => { + const invoke = vi.fn(() => Promise.resolve({ result: { ok: true as const, value: { accepted: true as const } } })) + const { source } = await bench(listOk(CATALOG), undefined, invoke) const outcome = source.onPick({ candidate: { name: 'commit-helper', description: 'commit flow' }, session: proj('s1'), @@ -323,21 +332,72 @@ describe('pick and codec', () => { via: 'menu', span: { start: 0, end: 4, draftRev: 7 }, }) - expect(outcome).toEqual({ text: '/commit-helper ' }) + if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected a claim outcome') + expect(outcome.claim.token).toBe('/commit-helper ') + await expect(outcome.claim.submit('check the fixture', {} as never)).resolves.toEqual({ kind: 'success' }) + expect(invoke).toHaveBeenCalledWith({ sessionId: sid('s1'), name: 'commit-helper', text: 'check the fixture' }) }) - it('codec projects clipboard `/name` and serializes the model form name', async () => { + it('submit omits blank args and folds an RPC refusal into an error outcome', async () => { + const invoke = vi.fn(() => Promise.resolve({ + result: { ok: false as const, error: { code: 'skill-not-invocable', message: 'nope', details: { name: 'deploy' } } }, + })) + const { source } = await bench(listOk(CATALOG), undefined, invoke) + const outcome = source.onPick({ + candidate: { name: 'deploy', description: 'deploy flow' }, + session: proj('s1'), + position: 'leading', + via: 'menu', + span: { start: 0, end: 4, draftRev: 7 }, + }) + if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected a claim outcome') + await expect(outcome.claim.submit(' ', {} as never)) + .resolves.toEqual({ kind: 'error', text: 'skill-not-invocable: nope' }) + expect(invoke).toHaveBeenCalledWith({ sessionId: sid('s1'), name: 'deploy' }) + }) + + it('drops the legacy reference codec (decision 21 removal cut)', async () => { const { source } = await bench(listOk(CATALOG)) - expect(source.codec!.clipboardText('deploy')).toBe('/deploy') - await expect(source.codec!.serialize('deploy', new AbortController().signal)) - .resolves.toBe('deploy') + expect(source.codec).toBeUndefined() }) }) describe('adjudication', () => { - it('never participates: no matchSpace/matchEnter hooks on the skill source', async () => { + it('claims an entered /name line, args-tolerant, once the catalog knows the name', async () => { + const invoke = vi.fn(() => Promise.resolve({ result: { ok: true as const, value: { accepted: true as const } } })) + const { source } = await bench(listOk(CATALOG), undefined, invoke) + const outcome = await source.matchEnter!(proj('s1'), '/deploy run the smoke suite', new AbortController().signal) + if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected a claim outcome') + expect(outcome.claim.token).toBe('/deploy ') + await outcome.claim.submit('run the smoke suite', {} as never) + expect(invoke).toHaveBeenCalledWith({ sessionId: sid('s1'), name: 'deploy', text: 'run the smoke suite' }) + }) + + it('answers undefined for unknown names, non-slash lines, and bare "/"', async () => { + const { source } = await bench(listOk(CATALOG)) + const signal = new AbortController().signal + await expect(source.matchEnter!(proj('s1'), '/unlisted do it', signal)).resolves.toBeUndefined() + await expect(source.matchEnter!(proj('s1'), 'plain prose', signal)).resolves.toBeUndefined() + await expect(source.matchEnter!(proj('s1'), '/', signal)).resolves.toBeUndefined() + }) + + it('never claims on space (menu and enter own the skill flows)', async () => { const { source } = await bench(listOk(CATALOG)) expect(typeof source.matchSpace).toBe('undefined') - expect(typeof source.matchEnter).toBe('undefined') + }) +}) + +describe('user-only marking', () => { + it('carries the user-only hint on candidates the model cannot invoke', async () => { + const rows: SkillRow[] = [ + { name: 'shared-skill', description: 'both surfaces', modelInvocable: true }, + { name: 'user-only-skill', description: 'user surface only', modelInvocable: false }, + ] + const { source } = await bench(listOk(rows)) + const candidates = await source.candidates(proj('s1'), req('')) + expect(candidates).toEqual([ + { name: 'shared-skill', description: 'both surfaces' }, + { name: 'user-only-skill', description: 'user surface only', hint: '仅用户' }, + ]) }) }) From 011e3e4e63f4cae663bf2aa7a4523c92c9786f68 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 01:05:08 +0800 Subject: [PATCH 045/109] feat(client): render user skill invocations as dedicated transcript cards A user/message carrying the skill-invocation source materializes as its own conversation node (name/args lifted off the source metadata, never re-parsed from the body) and renders as a right-aligned bubble: the /name chip plus the user's trailing text, with the injected collapsed behind a disclosure. A record with an unreadable name degrades to the injected-context row. --- packages/client/runtime/src/client/index.ts | 2 +- .../src/client/sessions/conversation.ts | 20 ++++++++++ .../src/client/sessions/transcript-adapter.ts | 16 +++++++- .../runtime/tests/transcript-adapter.spec.ts | 25 ++++++++++++ .../src/client/chat/MessageItem.module.css | 27 +++++++++++++ .../src/client/chat/MessageItem.tsx | 39 ++++++++++++++++++- .../ui-conversation/src/client/locales.ts | 2 + .../tests/chat-branch-tails.spec.tsx | 36 +++++++++++++++++ 8 files changed, 163 insertions(+), 4 deletions(-) diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 5a1677df96..a0aa4df482 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -49,7 +49,7 @@ export type { AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig, AssistantTiming, CodeSubCall, CommandNode, CompactionSummaryNode, ComposerPhase, ContextMessageNode, ConversationNode, ConversationSnapshot, ModelRetryNode, QueuedMessage, - RunningToolCall, + RunningToolCall, SkillInvocationNode, SteeringMessageNode, TodoItem, ToolResultNode, TurnErrorNode, UnknownSurfaceNode, UserMessageNode, } from './sessions/conversation.ts' export type { diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index fb2c281331..d66faf5e95 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -129,6 +129,25 @@ export interface ContextMessageNode { form: KnownContextForm | null } +/** + * A user-explicit skill invocation: the host injected the rendered skill as a + * user message carrying the `skill-invocation` source, so the card presents + * `/name args` from source metadata and collapses the injected body. + */ +export interface SkillInvocationNode { + kind: 'skill-invocation' + seq: number + /** Unix epoch ms from the source session event. */ + time: number + /** Invoked skill name read off the message source. */ + name: string + /** Trailing user text read off the message source, when recorded. */ + args?: string + /** Full injected model-facing content (collapsed by default in the UI). */ + content: readonly ContentBlock[] + source: unknown +} + /** Durable notice that a closed failed step is waiting for a model-request retry. */ export type ModelRetryNode = LlmRetryEventData & { kind: 'model-retry' @@ -245,6 +264,7 @@ export type ConversationNode = | AssistantMessageNode | SteeringMessageNode | ContextMessageNode + | SkillInvocationNode | ModelRetryNode | TurnErrorNode | ToolResultNode diff --git a/packages/client/runtime/src/client/sessions/transcript-adapter.ts b/packages/client/runtime/src/client/sessions/transcript-adapter.ts index d970be596b..4a05afee06 100644 --- a/packages/client/runtime/src/client/sessions/transcript-adapter.ts +++ b/packages/client/runtime/src/client/sessions/transcript-adapter.ts @@ -57,7 +57,20 @@ function materializeNode( stepTimings: ReadonlyMap, ): ConversationNode { switch (event.type) { - case 'user/message': + case 'user/message': { + // A user-explicit skill invocation carries its name (and optional args) + // on the source; the dedicated node lets the card render `/name args` + // from metadata instead of re-parsing the injected body. A record whose + // name is unreadable degrades to injected context below. + const source = event.data.source as { kind?: unknown; name?: unknown; args?: unknown } + if (source.kind === 'skill-invocation' && typeof source.name === 'string') { + return { + kind: 'skill-invocation', seq: event.seq, time: event.time, + name: source.name, + ...typeof source.args === 'string' ? { args: source.args } : {}, + content: event.data.content, source: event.data.source, + } + } // Injected context (plugin/goal source) folds to a context node, not a // user message; only a direct human prompt is a user node. A compaction // checkpoint never reaches here (isCompactCheckpoint routes it away). @@ -80,6 +93,7 @@ function materializeNode( kind: 'user', seq: event.seq, time: event.time, content: event.data.content, source: event.data.source, } + } case 'assistant/message': return { kind: 'assistant', seq: event.seq, time: event.time, diff --git a/packages/client/runtime/tests/transcript-adapter.spec.ts b/packages/client/runtime/tests/transcript-adapter.spec.ts index e4ef3b0e1a..e847c2cec7 100644 --- a/packages/client/runtime/tests/transcript-adapter.spec.ts +++ b/packages/client/runtime/tests/transcript-adapter.spec.ts @@ -164,6 +164,31 @@ describe('TranscriptAdapter', () => { expect(adapter.nodes().map(node => node.kind)).toEqual(['user', 'user', 'context']) }) + it('materializes a skill-invocation source as its dedicated node', () => { + const adapter = new TranscriptAdapter() + adapter.reset([ + at(0, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({ + content: [{ type: 'text', text: 'body\n\ncheck the fixture' }], + source: { kind: 'skill-invocation', name: 'hidden-demo', args: 'check the fixture' } as never, + }) }), + at(1, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({ + content: [{ type: 'text', text: 'body' }], + source: { kind: 'skill-invocation', name: 'bare-skill' } as never, + }) }), + ]) + const nodes = adapter.nodes() + expect(nodes.map(node => node.kind)).toEqual(['skill-invocation', 'skill-invocation']) + expect(nodes[0]).toMatchObject({ name: 'hidden-demo', args: 'check the fixture' }) + expect(nodes[1]).toMatchObject({ name: 'bare-skill' }) + expect((nodes[1] as { args?: string }).args).toBeUndefined() + // A malformed record (no readable name) degrades to injected context, not a crash. + adapter.append(at(2, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({ + content: [{ type: 'text', text: 'odd' }], + source: { kind: 'skill-invocation' } as never, + }) })) + expect(adapter.nodes().at(-1)?.kind).toBe('context') + }) + it('skips events core does not call surface-eligible, marker or not', () => { // The transcript is the append-origin surface, so log-only events (a chunk, // a turn boundary, a compact/* provenance record) and a future type core diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css index 5c07ace71e..4330cde32c 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css @@ -256,3 +256,30 @@ white-space: nowrap; vertical-align: baseline; } + +/* User-explicit skill invocation: the injected body collapses behind a + disclosure inside the user bubble. */ +.skillInvocationDetails { + margin-top: 6px; +} + +.skillInvocationSummary { + cursor: pointer; + font-size: 0.8em; + color: var(--dsw-alias-label-secondary); + user-select: none; +} + +.skillInvocationBody { + margin: 6px 0 0; + padding: 8px; + max-height: 320px; + overflow: auto; + border-radius: 6px; + background: var(--dsw-alias-bg-secondary, rgba(0, 0, 0, 0.06)); + font-family: var(--dsw-font-mono, monospace); + font-size: 0.78em; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; +} diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index 5473c9f8a2..661dd0cda5 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -7,8 +7,8 @@ import { memo, useEffect, useMemo, useState } from 'react' import type { ReactNode } from 'react' import type { - CompactionSummaryNode, ContextMessageNode, ModelRetryNode, SteeringMessageNode, - TurnErrorNode, UnknownSurfaceNode, UserMessageNode, + CompactionSummaryNode, ContextMessageNode, ModelRetryNode, SkillInvocationNode, + SteeringMessageNode, TurnErrorNode, UnknownSurfaceNode, UserMessageNode, } from '@deepseek-ai/dsh-client-runtime/client' import { JsonBlock, MessageText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps } from '../contract/slots.ts' @@ -22,6 +22,7 @@ export interface MessageItemProps { | UserMessageNode | SteeringMessageNode | ContextMessageNode + | SkillInvocationNode | CompactionSummaryNode | ModelRetryNode | TurnErrorNode @@ -193,6 +194,38 @@ function UserStyleBubble({ ) } +/** + * A user-explicit skill invocation: the right-aligned bubble presents the + * `/name args` gesture from source metadata (never re-parsed from the body), + * and the injected `` collapses behind a disclosure — the + * durable content is model-facing bulk, not conversation prose. + */ +function SkillInvocationRow({ node, t }: { + node: SkillInvocationNode + t: ChatViewSlotProps['t'] +}): ReactNode { + const { text } = contentText(node.content) + return ( +
+
+ {`/${node.name}`} + {node.args !== undefined && } +
+ {t('message.skillInvocation.expand')} +
{text}
+
+
+ +
+ ) +} + /** * Render one Host-authoritative pending steering item with the same visual * language as its eventual durable transcript node. @@ -254,6 +287,8 @@ export const MessageItem = memo(function MessageItem({ t={t} /> ) + case 'skill-invocation': + return case 'compaction': return case 'model-retry': diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index df107d2cd2..a340a2f634 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -79,6 +79,7 @@ export const zh = { 'message.context.recall.counts': '保留 {retained} 条 · 省略 {omitted} 条', 'message.context.recall.truncated': '已截断', 'message.steering': '插话', + 'message.skillInvocation.expand': '查看注入的 skill 内容', 'message.compaction': '上下文已压缩', 'message.compaction.expand': '点击查看压缩摘要', 'message.compaction.unavailable': '压缩摘要不可用', @@ -219,6 +220,7 @@ export const en = { 'message.context.recall.counts': '{retained} kept · {omitted} omitted', 'message.context.recall.truncated': 'truncated', 'message.steering': 'Interjection', + 'message.skillInvocation.expand': 'View injected skill content', 'message.compaction': 'Context compacted', 'message.compaction.expand': 'View compaction summary', 'message.compaction.unavailable': 'Compaction summary unavailable', diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index 3122b0fdc7..9471461cda 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -864,6 +864,42 @@ describe('MessageItem arms', () => { view.rerender() expect(view.getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 1s') }) + + it('skill-invocation renders the /name chip, args, and a collapsed injected body', () => { + const body = 'instructions\n\ncheck the fixture' + const view = render( + , + ) + const chip = view.container.querySelector('[data-ref-chip="skill"]') + expect(chip?.textContent).toBe('/hidden-demo') + const details = view.container.querySelector('details') + expect(details).toBeTruthy() + expect(details?.open).toBe(false) + expect(view.getByText('查看注入的 skill 内容')).toBeTruthy() + expect(view.container.querySelector('pre')?.textContent).toBe(body) + expect(view.container.querySelector('[data-skill-invocation]')).toBeTruthy() + }) + + it('skill-invocation without args renders only the chip line', () => { + const view = render( + x
' }] as never, + source: null, + }} + />, + ) + const bubble = view.container.querySelector('[data-skill-invocation]') + expect(bubble?.textContent).toContain('/bare-skill') + expect(bubble?.textContent).not.toContain('undefined') + }) }) describe('formatMessageClock', () => { From 6d09c315b93d3a5223cc31c30f99083af5530428 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 01:10:57 +0800 Subject: [PATCH 046/109] cleanup: remove private repository references --- ...andatory-app-attribution-headers.i18n.yaml | 4 +- ...06-21-mandatory-app-attribution-headers.md | 4 +- ...21-mandatory-app-attribution-headers.zh.md | 4 +- ...7-29-pnpm-setup-runner-isolation.i18n.yaml | 4 +- .../2026-07-29-pnpm-setup-runner-isolation.md | 2 +- ...26-07-29-pnpm-setup-runner-isolation.zh.md | 2 +- ...06-18-compaction-capability-seam.i18n.yaml | 4 +- .../2026-06-18-compaction-capability-seam.md | 2 +- ...026-06-18-compaction-capability-seam.zh.md | 2 +- ...-07-26-todo-parallel-in-progress.i18n.yaml | 4 +- .../2026-07-26-todo-parallel-in-progress.md | 2 +- ...2026-07-26-todo-parallel-in-progress.zh.md | 2 +- ...6-07-30-queued-manual-compaction.i18n.yaml | 4 +- .../2026-07-30-queued-manual-compaction.md | 2 +- .../2026-07-30-queued-manual-compaction.zh.md | 2 +- .../2026-08-05-pwsh-ui-bash-parity.i18n.yaml | 4 +- .../feature/2026-08-05-pwsh-ui-bash-parity.md | 2 +- .../2026-08-05-pwsh-ui-bash-parity.zh.md | 2 +- ...13-documentation-site-projection.i18n.yaml | 4 +- ...026-07-13-documentation-site-projection.md | 4 +- ...-07-13-documentation-site-projection.zh.md | 4 +- ...ence-based-larger-hosted-runners.i18n.yaml | 4 +- ...22-evidence-based-larger-hosted-runners.md | 8 +-- ...evidence-based-larger-hosted-runners.zh.md | 8 +-- ...efed-minimal-translation-updates.i18n.yaml | 4 +- ...-26-briefed-minimal-translation-updates.md | 2 +- ...-briefed-minimal-translation-updates.zh.md | 2 +- ...27-wine-windows-gates-experiment.i18n.yaml | 4 +- ...026-07-27-wine-windows-gates-experiment.md | 4 +- ...-07-27-wine-windows-gates-experiment.zh.md | 4 +- ...staller-adopts-existing-checkout.i18n.yaml | 4 +- ...7-31-installer-adopts-existing-checkout.md | 2 +- ...1-installer-adopts-existing-checkout.zh.md | 2 +- ...8-06-doc-site-carries-its-images.i18n.yaml | 4 +- .../2026-08-06-doc-site-carries-its-images.md | 4 +- ...26-08-06-doc-site-carries-its-images.zh.md | 4 +- .../2026-06-19-acp-snapshot-tests.i18n.yaml | 4 +- .../testing/2026-06-19-acp-snapshot-tests.md | 2 +- .../2026-06-19-acp-snapshot-tests.zh.md | 2 +- .github/workflows/ci.yml | 3 +- .../cordis-tutorial/01-first-plugin.i18n.yaml | 4 +- docs/cordis-tutorial/01-first-plugin.md | 2 +- docs/cordis-tutorial/01-first-plugin.zh.md | 2 +- .../02-lifecycle-and-effects.i18n.yaml | 4 +- .../02-lifecycle-and-effects.md | 2 +- .../02-lifecycle-and-effects.zh.md | 2 +- docs/cordis-tutorial/03-services.i18n.yaml | 4 +- docs/cordis-tutorial/03-services.md | 2 +- docs/cordis-tutorial/03-services.zh.md | 2 +- docs/cordis-tutorial/04-events.i18n.yaml | 4 +- docs/cordis-tutorial/04-events.md | 2 +- docs/cordis-tutorial/04-events.zh.md | 2 +- docs/cordis-tutorial/05-config.i18n.yaml | 4 +- docs/cordis-tutorial/05-config.md | 2 +- docs/cordis-tutorial/05-config.zh.md | 2 +- .../06-composition-and-hmr.i18n.yaml | 4 +- .../cordis-tutorial/06-composition-and-hmr.md | 2 +- .../06-composition-and-hmr.zh.md | 2 +- .../07-into-the-harness.i18n.yaml | 4 +- docs/cordis-tutorial/07-into-the-harness.md | 2 +- .../cordis-tutorial/07-into-the-harness.zh.md | 2 +- docs/cordis-tutorial/index.i18n.yaml | 4 +- docs/cordis-tutorial/index.md | 4 +- docs/cordis-tutorial/index.zh.md | 4 +- docs/user/guide/quickstart.i18n.yaml | 4 +- docs/user/guide/quickstart.md | 2 +- docs/user/guide/quickstart.zh.md | 2 +- examples/acp-agent/tests/acp.snapshot.ts | 4 +- .../headless-agent/tests/compaction.e2e.ts | 4 +- examples/mcp-memory/README.i18n.yaml | 4 +- examples/mcp-memory/README.md | 2 +- examples/mcp-memory/README.zh.md | 2 +- package.json | 1 + packages/llm/llm/README.i18n.yaml | 4 +- packages/llm/llm/README.md | 2 +- packages/llm/llm/README.zh.md | 2 +- packages/llm/llm/src/attribution.ts | 3 +- packages/llm/llm/src/call-config.ts | 2 + packages/sdk/telemetry/README.i18n.yaml | 4 +- packages/sdk/telemetry/README.md | 4 +- packages/sdk/telemetry/README.zh.md | 4 +- packages/sdk/telemetry/src/reporter.ts | 6 +- scripts/install.sh | 4 +- scripts/project-doc-site.spec.ts | 6 +- scripts/project-doc-site.ts | 4 +- scripts/run-gates.spec.ts | 6 ++ scripts/run-gates.ts | 1 + .../verify-public-repository-links.spec.ts | 16 +++++ scripts/verify-public-repository-links.ts | 64 +++++++++++++++++++ website/.vitepress/config.ts | 6 +- 90 files changed, 229 insertions(+), 137 deletions(-) create mode 100644 scripts/verify-public-repository-links.spec.ts create mode 100644 scripts/verify-public-repository-links.ts diff --git a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml index 946d5a6117..b6788a47ef 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.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-06-21-mandatory-app-attribution-headers.md -2026-06-21-mandatory-app-attribution-headers.md: 28432008c354cbbb6e364746338627a26b464b0c -2026-06-21-mandatory-app-attribution-headers.zh.md: 4fb3acd72aba4bebe751f57ac0f89f776d1f1f39 +2026-06-21-mandatory-app-attribution-headers.md: ad9d65805c8f0c96bd811b5036310d019760627e +2026-06-21-mandatory-app-attribution-headers.zh.md: 3021c7fcca00f2e929d997625c303f9a27dbf673 diff --git a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md index 28432008c3..ad9d65805c 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md +++ b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md @@ -32,7 +32,7 @@ The provider-neutral identity is owned by `dsh-llm` (`packages/llm/llm/src/attri - product token for `User-Agent`: `deepseek-harness` (continuity with the pre-Agent Note wire value and the repo/org identity) - version: read from the owning package's manifest via `createRequire`, never a hand-copied constant -- app URL: `https://github.com/deepseek-ai/deepseek-harness-sdk` - the planned public home; [#1972](https://github.com/deepseek-harness/deepseek-harness/issues/1972) tracks making it reachable before release +- app URL: `https://github.com/deepseek-ai/deepseek-harness-sdk` - the planned public home, which must exist before release The default is mandatory and non-empty. White-label deployments pass their own `AppIdentity` to `attributionHeaders(identity)` - the override seam is the function parameter, with no deployment config plumbing until a consumer needs it - and omission falls back to the harness default rather than suppressing attribution. There is no per-request API for the model, user prompt, session id, cwd, user email, API key owner, or local machine identity to influence these fields. @@ -77,7 +77,7 @@ The landed contract: **Providers see that traffic comes from the harness.** That is the point, but it means deployments that previously blended into generic SDK traffic become identifiable. Mitigation: send only static public product data and let forks/white-label deployments pass their own `AppIdentity`. -**The app URL points at a repository that does not exist yet.** `deepseek-ai/deepseek-harness-sdk` is the planned public home; until it is created the URL is a dangling promise. [#1972](https://github.com/deepseek-harness/deepseek-harness/issues/1972) owns creating it or correcting the final URL before release. +**The app URL points at a repository that does not exist yet.** `deepseek-ai/deepseek-harness-sdk` is the planned public home; until it is created the URL is a dangling promise that blocks release. **Header support differs by client library.** The hand-rolled adapter sets headers directly; the pi-ai-backed adapter depends on pi-ai continuing to honor `StreamOptions.headers` (merged last over provider defaults). The wire-level mock-server tests are the guard: if a pi-ai upgrade stops delivering the header, the suite goes red. This is useful pressure on the abstraction: a provider adapter that cannot set mandatory headers cannot fully implement the harness LLM contract. diff --git a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md index 4fb3acd72a..3021c7fcca 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md @@ -32,7 +32,7 @@ LLM(大语言模型)提供方请求应当标识发出请求的产品。这 - `User-Agent` 的产品 token:`deepseek-harness`(与 Agent Note 之前的线路值及仓库/组织身份保持连续性) - 版本:通过 `createRequire` 从所属包的 manifest(元数据清单)读取,绝不手动复制常量 -- 应用 URL:`https://github.com/deepseek-ai/deepseek-harness-sdk`——计划中的公开主页;[#1972](https://github.com/deepseek-harness/deepseek-harness/issues/1972) 跟踪在发布前使其可访问 +- 应用 URL:`https://github.com/deepseek-ai/deepseek-harness-sdk`——计划中的公开主页,且必须在发布前实际存在 默认值是强制的且非空。白标部署通过向 `attributionHeaders(identity)` 传入自己的 `AppIdentity` 来覆盖——覆盖 seam 就是函数参数,在有消费方需要之前不做部署配置管道——省略时回退到 harness 默认值而非抑制归属。没有逐请求 API 允许模型、用户提示词、会话 id、cwd、用户邮箱、API key 所有者或本地机器身份影响这些字段。 @@ -77,7 +77,7 @@ LLM(大语言模型)提供方请求应当标识发出请求的产品。这 **提供方看到流量来自 harness。** 这正是目的,但意味着此前混在通用 SDK 流量中的部署变得可识别。缓解措施:仅发送静态公开产品数据,并允许 fork/白标部署传入自己的 `AppIdentity`。 -**应用 URL 指向一个尚不存在的仓库。** `deepseek-ai/deepseek-harness-sdk` 是计划中的公开主页;在它创建之前,该 URL 是一个悬空承诺。[#1972](https://github.com/deepseek-harness/deepseek-harness/issues/1972) 负责在发布前创建该仓库或校正最终 URL。 +**应用 URL 指向一个尚不存在的仓库。** `deepseek-ai/deepseek-harness-sdk` 是计划中的公开主页;在它创建之前,该 URL 是一个阻塞发布的悬空承诺。 **不同客户端库的头部支持有差异。** 手写适配器直接设置头部;基于 pi-ai 的适配器依赖 pi-ai 继续尊重 `StreamOptions.headers`(最后合并覆盖提供方默认值)。线路级 mock 服务器测试是守卫:如果 pi-ai 升级后不再投递该头部,套件会变红。这对抽象施加了有益的压力:一个无法设置强制头部的提供方适配器不能完整实现 harness 的 LLM 契约。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.i18n.yaml index fb5ee5debc..b1bdfe74c2 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.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-29-pnpm-setup-runner-isolation.md -2026-07-29-pnpm-setup-runner-isolation.md: 743535d0394cbea0374c412ba6968910ce858de4 -2026-07-29-pnpm-setup-runner-isolation.zh.md: 1e51070f88dead17b9d3f5625e337c558786aba2 +2026-07-29-pnpm-setup-runner-isolation.md: 74b672b3f90ea445ad1a8e283a5904056059b2f8 +2026-07-29-pnpm-setup-runner-isolation.zh.md: 32c667dc09e561504e8e053bf2a338ed2190d9e8 diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.md b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.md index 743535d039..74b672b3f9 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.md @@ -6,7 +6,7 @@ English | [中文](2026-07-29-pnpm-setup-runner-isolation.zh.md) ## Problem -`pnpm/action-setup@v4` defaults its install destination to `~/setup-pnpm` and replaces that directory during setup. The self-hosted CI failover runs six GitHub Actions runner services under one VM user, so concurrent jobs shared the same destination. In [run 30375670773](https://github.com/deepseek-harness/deepseek-harness/actions/runs/30375670773), three jobs entered pnpm setup within 73 milliseconds; one setup removed another process's current working directory and two jobs failed in Node's `uv_cwd` initialization. A retry on another runner passed, making the failure timing-dependent rather than a repository-test regression. +`pnpm/action-setup@v4` defaults its install destination to `~/setup-pnpm` and replaces that directory during setup. The self-hosted CI failover runs six GitHub Actions runner services under one VM user, so concurrent jobs shared the same destination. In the reproducing run, three jobs entered pnpm setup within 73 milliseconds; one setup removed another process's current working directory and two jobs failed in Node's `uv_cwd` initialization. A retry on another runner passed, making the failure timing-dependent rather than a repository-test regression. ## Decision diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.zh.md b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.zh.md index 1e51070f88..32c667dc09 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -`pnpm/action-setup@v4` 的安装目标目录默认为 `~/setup-pnpm`,并会在设置期间替换该目录。自托管 CI 故障切换在同一个 VM 用户下运行六个 GitHub Actions runner 服务,因此并发作业会共用同一目标目录。在 [run 30375670773](https://github.com/deepseek-harness/deepseek-harness/actions/runs/30375670773) 中,三个作业在 73 毫秒内进入 pnpm 设置;其中一个设置过程删除了另一个进程的当前工作目录,导致两个作业在 Node 的 `uv_cwd` 初始化阶段失败。换到另一台 runner 重试后通过,说明该故障取决于时序,并非仓库测试回归。 +`pnpm/action-setup@v4` 的安装目标目录默认为 `~/setup-pnpm`,并会在设置期间替换该目录。自托管 CI 故障切换在同一个 VM 用户下运行六个 GitHub Actions runner 服务,因此并发作业会共用同一目标目录。在复现运行中,三个作业在 73 毫秒内进入 pnpm 设置;其中一个设置过程删除了另一个进程的当前工作目录,导致两个作业在 Node 的 `uv_cwd` 初始化阶段失败。换到另一台 runner 重试后通过,说明该故障取决于时序,并非仓库测试回归。 ## 决策 diff --git a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml index f071577bdd..9e195a4b1d 100644 --- a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.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-06-18-compaction-capability-seam.md -2026-06-18-compaction-capability-seam.md: efb37482270a7952f6af6596f9afd12f17048bcc -2026-06-18-compaction-capability-seam.zh.md: 214832923c4e24835e7b25a5bbf2b1bcd62dff42 +2026-06-18-compaction-capability-seam.md: 8dcbe74429a620027a570124383442b969c12196 +2026-06-18-compaction-capability-seam.zh.md: 27b63f29c2e6f35637185b47c882ae42e5d41088 diff --git a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md index efb3748227..8dcbe74429 100644 --- a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md +++ b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md @@ -131,4 +131,4 @@ The lifecycle boundary makes crash state unambiguous: - **Loop:** Tests pin pre-step after the preceding `step/end` and before the next `step/start`, actual `agent/request` routing, closed failed steps, fresh retry numbering, and complete thrown/in-band overflow → compaction → reconstructed retry composition. - **Manual:** Maintenance serialization, marker ordering, injection retention, live/stale orphan classification, cancellation, close/flush failures, command mapping, and the queued TUI journey are pinned without a model key. - **With-key e2e:** A real model and bash session with lowered limits triggers compaction, records a complete `compact/start…end` pair, shrinks the surface, and finishes the task. -- **Snapshot gap:** The summarization call is session-associated and logs `compact/summary`, but ordinary transcript replay does not derive its auxiliary response. [#1971](https://github.com/deepseek-harness/deepseek-harness/issues/1971) tracks a keyless assembled scenario with an explicit replay override. +- **Snapshot gap:** The summarization call is session-associated and logs `compact/summary`, but ordinary transcript replay does not derive its auxiliary response; keyless assembled coverage therefore needs an explicit replay override. diff --git a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md index 214832923c..27b63f29c2 100644 --- a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md +++ b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md @@ -131,4 +131,4 @@ compact/end → log-only. Releases the lock (carries `error` on a recoverab - **循环测试:** 测试固定 pre-step 发生在前一个 `step/end` 之后、下一个 `step/start` 之前,使用实际 `agent/request` 路由,关闭失败步骤,分配新的重试编号,并覆盖完整的抛出/带内溢出 → 压缩 → 重建重试组合。 - **手动测试:** 无需模型密钥即可固定 maintenance 串行化、标记顺序、注入保留、活动/陈旧未匹配标记分类、取消、闭合/flush 失败、命令映射以及排队 TUI 流程。 - **带密钥 e2e:** 真实模型和 bash 会话在降低的限制下触发压缩,记录完整的 `compact/start…end` 对,缩小 surface,并完成任务。 -- **快照缺口:** 摘要调用与会话关联并记录 `compact/summary`,但普通 transcript(文本记录)回放不会派生其辅助响应。[#1971](https://github.com/deepseek-harness/deepseek-harness/issues/1971) 跟踪一个带显式回放 override 的无密钥组装场景。 +- **快照缺口:** 摘要调用与会话关联并记录 `compact/summary`,但普通 transcript(文本记录)回放不会派生其辅助响应;因此,要实现无密钥的组装态覆盖,就必须显式提供回放 override。 diff --git a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml index 6b0ce3f378..8a92dd2fd1 100644 --- a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.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-26-todo-parallel-in-progress.md -2026-07-26-todo-parallel-in-progress.md: 2805ef894050d1b1cffe06fce59a4984d463f8d1 -2026-07-26-todo-parallel-in-progress.zh.md: 16b32daa05b10f24eacde4cec9622b2159cdef09 +2026-07-26-todo-parallel-in-progress.md: 8d047f33ab1aebd8c0de2a0a5e90e7efb1b28154 +2026-07-26-todo-parallel-in-progress.zh.md: 26b7081e7b4e3688a46a2857da91ba45328532a9 diff --git a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md index 2805ef8940..8d047f33ab 100644 --- a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md +++ b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md @@ -37,7 +37,7 @@ The durable-log invariant deliberately does NOT follow the flag. A log written w ## The display surfaces are part of the change -Lifting the cap makes a list shape reachable that no renderer had ever received, so this branch stacks on the [web todo display](2026-07-23-web-todo-display.md) rather than landing beside it: both change `tool-todo`, and the GUI is where a parallel plan becomes visible. Two web sites derived their one-line summary with `todos.find(t => t.status === 'in_progress')` — the collapsed plan-strip header and the `todo_write` row — and under the old cap that `find` was total, since at most one item could match. With several active it silently dropped every active item but the first: a four-item plan with three running tasks collapsed to the name of one, and the row read `1/4 已完成 · ` while two others were in flight. The expanded list was always correct (it maps every item), which is why neither PR's tests caught it — only the collapsed header and the row lost information. The panel redesign in [#740](https://github.com/deepseek-harness/deepseek-harness/pull/740) has since replaced the collapsed header's named hint with `·`-joined per-status counts (localized, `1 completed · 2 in progress · 1 pending`, zero-count segments omitted), which reports parallel work correctly and needs no name to truncate; the row is the one site this branch still had to fix. +Lifting the cap makes a list shape reachable that no renderer had ever received, so this branch stacks on the [web todo display](2026-07-23-web-todo-display.md) rather than landing beside it: both change `tool-todo`, and the GUI is where a parallel plan becomes visible. Two web sites derived their one-line summary with `todos.find(t => t.status === 'in_progress')` — the collapsed plan-strip header and the `todo_write` row — and under the old cap that `find` was total, since at most one item could match. With several active it silently dropped every active item but the first: a four-item plan with three running tasks collapsed to the name of one, and the row read `1/4 已完成 · ` while two others were in flight. The expanded list was always correct (it maps every item), which is why neither PR's tests caught it — only the collapsed header and the row lost information. The panel redesign replaced the collapsed header's named hint with `·`-joined per-status counts (localized, `1 completed · 2 in progress · 1 pending`, zero-count segments omitted), which reports parallel work correctly and needs no name to truncate; the row is the one site this branch still had to fix. The row takes `planSummary` in `toolviews/plan-summary.ts`. It names the first active item and counts the rest, so the row reports how many tasks are running instead of implying one. Naming every active item was rejected: the row is a single line, and an unbounded join would overflow it — the count degrades predictably where a list does not. The derivation sits inside the toolviews domain rather than in `contract/`, the inter-domain face: the panel computes its own counts inline and shares nothing with the row, so a contract module would declare a sharing relationship that no longer exists. diff --git a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md index 16b32daa05..26b7081e7b 100644 --- a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md +++ b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md @@ -37,7 +37,7 @@ Status: implemented ## 展示面是本次改动的一部分 -解除上限使一种此前任何渲染器都不曾收到的列表形状变得可达,因此本分支 stack(栈叠)在 [web todo 展示](2026-07-23-web-todo-display.md)之上,而不是与之并行落地:两者都改 `tool-todo`,而 GUI 正是并行计划变得可见的地方。web 有两处用 `todos.find(t => t.status === 'in_progress')` 推导单行摘要——折叠态的计划横条表头与 `todo_write` 工具行——在旧上限下这个 `find` 是完备的,因为最多只能有一个条目匹配。一旦有多个活跃项,它会静默丢掉除第一个之外的全部活跃条目:一个四条目、三个任务在跑的计划折叠后只显示其中一个的名字,工具行读作 `1/4 已完成 · <一个任务>`,而另外两个仍在进行。展开态的列表始终正确(它遍历每个条目),这也是两个 PR 的测试都没抓到它的原因——只有折叠表头与工具行丢失了信息。其后 [#740](https://github.com/deepseek-harness/deepseek-harness/pull/740) 的面板重做已把折叠表头的具名提示换成以 `·` 连接的各状态计数(本地化后形如 `1 已完成 · 2 进行中 · 1 待处理`,计数为零的段落省略),它能正确报告并行工作,且不需要任何可被截断的名字;工具行才是本分支仍需修的那一处。 +解除上限使一种此前任何渲染器都不曾收到的列表形状变得可达,因此本分支 stack(栈叠)在 [web todo 展示](2026-07-23-web-todo-display.md)之上,而不是与之并行落地:两者都改 `tool-todo`,而 GUI 正是并行计划变得可见的地方。web 有两处用 `todos.find(t => t.status === 'in_progress')` 推导单行摘要——折叠态的计划横条表头与 `todo_write` 工具行——在旧上限下这个 `find` 是完备的,因为最多只能有一个条目匹配。一旦有多个活跃项,它会静默丢掉除第一个之外的全部活跃条目:一个四条目、三个任务在跑的计划折叠后只显示其中一个的名字,工具行读作 `1/4 已完成 · <一个任务>`,而另外两个仍在进行。展开态的列表始终正确(它遍历每个条目),这也是两个 PR 的测试都没抓到它的原因——只有折叠表头与工具行丢失了信息。面板重做把折叠表头的具名提示换成以 `·` 连接的各状态计数(本地化后形如 `1 已完成 · 2 进行中 · 1 待处理`,计数为零的段落省略),它能正确报告并行工作,且不需要任何可被截断的名字;工具行才是本分支仍需修的那一处。 工具行改用 `toolviews/plan-summary.ts` 中的 `planSummary`。它给出第一个活跃条目,并计数其余活跃项,因此工具行报告的是有多少任务在跑,而不是暗示只有一个。列出全部活跃条目被否决了:工具行是单行,无上界的拼接会溢出——在列表做不到的地方,计数能够可预测地降级。该推导放在 toolviews 域内而非 `contract/`(域间共享面):面板自行内联计算其计数,与工具行不共享任何东西,因此放进 contract 会声明一种已不存在的共享关系。 diff --git a/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.i18n.yaml index f06403d181..1ceb5a5213 100644 --- a/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.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-queued-manual-compaction.md -2026-07-30-queued-manual-compaction.md: 4b7a905712a01948146b8830dfc037185162eefc -2026-07-30-queued-manual-compaction.zh.md: 15a42de3536f2da5304e77ce3cb282029856ba6d +2026-07-30-queued-manual-compaction.md: 5100808ada4b7b284228113584e577d46ff91101 +2026-07-30-queued-manual-compaction.zh.md: 29c3b921d0d59527aa1d8af1c91d085b52045377 diff --git a/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.md b/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.md index 4b7a905712..5100808ada 100644 --- a/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.md +++ b/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.md @@ -75,7 +75,7 @@ Once a transaction has appended its start, every later failure makes one closing ### Reference implementation boundaries -[PR #835](https://github.com/deepseek-harness/deepseek-harness/pull/835) was used as a reference implementation for the command, reservation, tests, and snapshot shape, but was not merged. Its process-local `WeakSet` lock and locked/unlocked method splits were considered and not adopted because the durable bracket is the single reachable lock. +An unmerged reference implementation informed the command, reservation, tests, and snapshot shape. Its process-local `WeakSet` lock and locked/unlocked method splits were considered and not adopted because the durable bracket is the single reachable lock. That reference also carried client-side replacement-anchor machinery to preserve transcript placement. The log-ordered transcript projection already consumes compaction from event order and does not consult mutable surface positions, so those anchors were considered and not adopted. diff --git a/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.zh.md b/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.zh.md index 15a42de353..29c3b921d0 100644 --- a/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.zh.md @@ -75,7 +75,7 @@ DSH 有意在调用摘要器前记录 `compact/start`。缓慢或崩溃的尝试 ### 参考实现边界 -[PR #835](https://github.com/deepseek-harness/deepseek-harness/pull/835) 用作命令、预留、测试与快照结构的参考实现,但未被合并。它的进程本地 `WeakSet` 锁与 locked/unlocked 方法拆分经过评估后未被采用,因为持久标记对是唯一可达的锁。 +一个未合并的参考实现为命令、预留、测试与快照结构提供了参考。它的进程本地 `WeakSet` 锁与 locked/unlocked 方法拆分经过评估后未被采用,因为持久标记对是唯一可达的锁。 该参考实现还包含客户端侧替换锚点机制,用于保留 transcript(文本记录)位置。按日志顺序排列的 transcript 投影已经从事件顺序消费压缩,并且不会查询可变 surface 位置,因此这些锚点经过评估后未被采用。 diff --git a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.i18n.yaml index dcb3a9406b..51401adfdc 100644 --- a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.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-08-05-pwsh-ui-bash-parity.md -2026-08-05-pwsh-ui-bash-parity.md: 6bbdb0e6bc69ef1af03a6a9146f83b84754cb2a6 -2026-08-05-pwsh-ui-bash-parity.zh.md: 75f3a3ddec002acaa1755c81114b0f122ab80593 +2026-08-05-pwsh-ui-bash-parity.md: 815b448b894e9c53b4c4a2076f6b94fdd316dd35 +2026-08-05-pwsh-ui-bash-parity.zh.md: 967c5a9e1409028043dc5028fdca640ddfeb1acc diff --git a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.md b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.md index 6bbdb0e6bc..815b448b89 100644 --- a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.md +++ b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.md @@ -6,7 +6,7 @@ English | [中文](2026-08-05-pwsh-ui-bash-parity.zh.md) ## Problem -The [pwsh tool bash parity decision](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) made `dsh-tool-pwsh` behaviorally interchangeable with `dsh-tool-bash` for execution, markers, and background tasks, but explicitly deferred the human-visible half: a completed pwsh foreground call presented as a generic `console`-fenced card while the bash tool's completed call presented as a terminal card with a parsed exit-status pill. The roadmap that owned this gap ([Windows defaults to pwsh](../../proposed/feature/2026-08-01-windows-pwsh-default.md)) named "pwsh TUI/GUI rendering" as stage 2 — but the TUI package was removed ([`ed30088adb`](https://github.com/deepseek-harness/deepseek-harness/commit/ed30088adb)), leaving the Web surface as the only UI the gap affects. +The [pwsh tool bash parity decision](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) made `dsh-tool-pwsh` behaviorally interchangeable with `dsh-tool-bash` for execution, markers, and background tasks, but explicitly deferred the human-visible half: a completed pwsh foreground call presented as a generic `console`-fenced card while the bash tool's completed call presented as a terminal card with a parsed exit-status pill. The roadmap that owned this gap ([Windows defaults to pwsh](../../proposed/feature/2026-08-01-windows-pwsh-default.md)) named "pwsh TUI/GUI rendering" as stage 2, but the TUI package was removed, leaving the Web surface as the only UI the gap affects. ## Decision diff --git a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.zh.md b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.zh.md index 75f3a3ddec..967c5a9e14 100644 --- a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.zh.md @@ -6,7 +6,7 @@ Status: implemented ## Problem -[pwsh 工具与 bash 对齐决策](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) 让 `dsh-tool-pwsh` 在执行、marker 与后台任务上行为可互换,但明确推迟了面向人类的一半:完成的 pwsh 前台调用呈现为通用 `console` 围栏卡片,而 bash 工具的完成调用呈现为带解析退出状态 pill 的 terminal 卡。拥有此缺口的路线图([Windows 默认改用 pwsh](../../proposed/feature/2026-08-01-windows-pwsh-default.md))把 "pwsh TUI/GUI 渲染" 列为阶段 2——但 TUI 包已被移除([`ed30088adb`](https://github.com/deepseek-harness/deepseek-harness/commit/ed30088adb)),Web 表面成为该缺口唯一影响的 UI。 +[pwsh 工具与 bash 对齐决策](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) 让 `dsh-tool-pwsh` 在执行、marker 与后台任务上行为可互换,但明确推迟了面向人类的一半:完成的 pwsh 前台调用呈现为通用 `console` 围栏卡片,而 bash 工具的完成调用呈现为带解析退出状态 pill 的 terminal 卡。拥有此缺口的路线图([Windows 默认改用 pwsh](../../proposed/feature/2026-08-01-windows-pwsh-default.md))把 "pwsh TUI/GUI 渲染" 列为阶段 2,但 TUI 包已被移除,使 Web 表面成为该缺口唯一影响的 UI。 ## Decision diff --git a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.i18n.yaml b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.i18n.yaml index 7fa4d3fbba..07e0a89d0f 100644 --- a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.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-07-13-documentation-site-projection.md -2026-07-13-documentation-site-projection.md: f19d9b309aa22821a75086dc07ee302097631ba0 -2026-07-13-documentation-site-projection.zh.md: cc5e94e709f0639fd35ad81165b199cc5c9effc0 +2026-07-13-documentation-site-projection.md: d9af915754fa6a1df51a27d18d412597472aaa73 +2026-07-13-documentation-site-projection.zh.md: 5b6de4b3425b6d20335a0f8055468ced414d4034 diff --git a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md index f19d9b309a..d9af915754 100644 --- a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md +++ b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md @@ -18,7 +18,9 @@ Canonical Markdown remains in the repository tier that owns it. Product-facing g Locale home projections retain only the canonical YAML frontmatter. The repository-facing body can keep its H1 and bilingual source links, while the VitePress home theme owns the rendered hero and features and the site navigation owns locale switching. -The projector parses Markdown links without reserializing the document. A link to another published source becomes a site-relative route; a link to an unpublished repository file becomes a GitHub source link; a repository image is copied into the generated tree and referenced from there ([why](2026-08-06-doc-site-carries-its-images.md)). Missing relative targets fail projection. Unit tests pin these transformations, and `docs:check` runs the projector tests plus a production VitePress build as part of `doc-sync` and the parallel documentation gates. +The projector parses Markdown links without reserializing the document. A link to another published source becomes a site-relative route; a link to an unpublished repository file becomes a source link under the public `deepseek-ai/deepseek-harness-sdk` home; a repository image is copied into the generated tree and referenced from there ([why](2026-08-06-doc-site-carries-its-images.md)). Missing relative targets fail projection. Unit tests pin these transformations, and `docs:check` runs the projector tests plus a production VitePress build as part of `doc-sync` and the parallel documentation gates. + +`verify-public-repository-links` rejects internal repository remotes from tracked files. Public source links use the public home, while work tracking stays in repository metadata and source carries a TODO only when the local boundary matters to maintainers. `website/AGENTS.md` is the only maintained Markdown file in the website subtree. The projector test enumerates tracked and unignored files and rejects any other website Markdown, so site-specific locale, route, API, or generated source copies cannot bypass the publication manifest. diff --git a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md index cc5e94e709..5b6de4b342 100644 --- a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md +++ b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md @@ -18,7 +18,9 @@ Status: implemented 各 locale 的首页投影只保留权威 YAML frontmatter。面向仓库的正文可以保留其 H1 和双语源文件链接,而 VitePress 首页主题负责渲染 hero 与功能区,网站导航负责切换 locale。 -投影器解析 Markdown 链接,但不会重新序列化文档。指向另一个已发布源文件的链接会变成站内相对路由;指向未发布仓库文件的链接会变成 GitHub 源文件链接;仓库图片会被拷贝进生成树并从那里引用([原因](2026-08-06-doc-site-carries-its-images.md))。相对目标不存在时,投影会失败。单元测试会锁定这些转换行为,`docs:check` 则运行投影器测试和 VitePress 生产构建,并将二者纳入 `doc-sync` 和并行文档门禁。 +投影器解析 Markdown 链接,但不会重新序列化文档。指向另一个已发布源文件的链接会变成站内相对路由;指向未发布仓库文件的链接会变成公开 `deepseek-ai/deepseek-harness-sdk` 主页下的源文件链接;仓库图片会被拷贝进生成树并从那里引用([原因](2026-08-06-doc-site-carries-its-images.md))。相对目标不存在时,投影会失败。单元测试会锁定这些转换行为,`docs:check` 则运行投影器测试和 VitePress 生产构建,并将二者纳入 `doc-sync` 和并行文档门禁。 + +`verify-public-repository-links` 会拒绝已跟踪文件中的内部仓库远程链接。公开源文件链接使用公开主页,而工作跟踪留在仓库元数据中;只有本地边界对维护者有意义时,源文件才保留 TODO。 `website/AGENTS.md` 是网站子树中唯一维护的 Markdown 文件。投影器测试会枚举所有已跟踪文件和未被忽略的未跟踪文件,并拒绝网站中的任何其他 Markdown,因此网站专用的 locale、路由、API 或生成源文件副本无法绕过发布 manifest。 diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml index 8ccd5ca13e..1dc5d79676 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.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-07-22-evidence-based-larger-hosted-runners.md -2026-07-22-evidence-based-larger-hosted-runners.md: d46b8291ec05e997728da76354354f9e36bd2fb4 -2026-07-22-evidence-based-larger-hosted-runners.zh.md: f43712859d8351ffff45c7b6d5eb2b65015ee4c3 +2026-07-22-evidence-based-larger-hosted-runners.md: 53cc86efce9061c8f9836a17cb35ebb128085b7a +2026-07-22-evidence-based-larger-hosted-runners.zh.md: 0484548c76cb7eed11dc4235ef024a700499ef6a diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md index d46b8291ec..53cc86efce 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md @@ -26,7 +26,7 @@ The artifact boundary remains explicit. `scripts/publint-all.ts` calls publint's Windows shares one 32-core setup across the blocking build and production site plus observational built-artifact contracts. Linux owns the duplicate lint, coverage, and snapshot inventories because running those observational copies on Windows extends the paid critical path without adding a blocking platform claim. -An [exact-head all-size benchmark](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29908491351) ran the complete unsharded primary Node aggregate on every Linux pool before the eager-build correction: +An exact-head all-size benchmark ran the complete unsharded primary Node aggregate on every Linux pool before the eager-build correction: | Complete Linux primary | 4 cores | 8 cores | 16 cores | 32 cores | 64 cores | 96 cores | |---|---:|---:|---:|---:|---:|---:| @@ -40,13 +40,13 @@ The same benchmark measured the required Windows build surfaces across every pro |---|---:|---:|---:|---:|---:|---:| | Active time | 152 s | 104 s | 104 s | 92 s | 103 s | 110 s | -Repository work gains little above 16 Windows cores, but the 32-core pool can start the complete outer inventory together. A [retargeted production validation](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29907581119/attempts/2) completed the full one-box Windows inventory in 173 seconds, including coverage and snapshot replay, so Windows remains consolidated. +Repository work gains little above 16 Windows cores, but the 32-core pool can start the complete outer inventory together. A retargeted production validation completed the full one-box Windows inventory in 173 seconds, including coverage and snapshot replay, so Windows remains consolidated. -The larger client package graph makes cache mechanics and scheduler pressure part of the measured workload. In [one exact-head candidate run](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29912577681), Linux spent 39 seconds in repository gates but 69 seconds in the complete job, while Windows spent 117 seconds in repository gates and 228 seconds in the complete job. The Windows pnpm cache downloaded its 154 MB archive in about two seconds but spent 27 seconds extracting it, followed by a 23-second install and a 14-second post-job save. A [cacheless all-size trace](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29913033155) completed the same 32-core Windows install in 27 seconds. A future larger-runner rollout therefore needs complete-job measurements rather than gate-only timing. +The larger client package graph makes cache mechanics and scheduler pressure part of the measured workload. In one exact-head candidate run, Linux spent 39 seconds in repository gates but 69 seconds in the complete job, while Windows spent 117 seconds in repository gates and 228 seconds in the complete job. The Windows pnpm cache downloaded its 154 MB archive in about two seconds but spent 27 seconds extracting it, followed by a 23-second install and a 14-second post-job save. A cacheless all-size trace completed the same 32-core Windows install in 27 seconds. A future larger-runner rollout therefore needs complete-job measurements rather than gate-only timing. Host setup remains part of any comparison. A standard Node 26 job once spent 36 of its 67 seconds in `Set up job`, while `actions/setup-node` spent 46.56 seconds printing cached Windows environment details after finding Node in the hosted toolcache. A Linux candidate also spent 18 seconds registering a 50 KB Bubblewrap package because the hosted image scanned 202,507 package-database files. [`scripts/prepare-ci-bubblewrap.sh`](../../../../scripts/prepare-ci-bubblewrap.sh) instead verifies and extracts the pinned payload into the ephemeral runner directory, runs a functional confinement probe, and overlaps that preparation with dependency installation. -Inner and outer worker limits are separate controls. An [exact-head 32-worker ESLint experiment](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29918329463) slowed lint to 52.28 seconds and coverage to 42.71 seconds, where an adapter idle-timeout test failed. A later 8-gate trace reduced coverage to 35.17 seconds but delayed the production-site build until the aggregate reached 41.06 seconds. Core count therefore does not justify copying an equally large worker limit. +Inner and outer worker limits are separate controls. An exact-head 32-worker ESLint experiment slowed lint to 52.28 seconds and coverage to 42.71 seconds, where an adapter idle-timeout test failed. A later 8-gate trace reduced coverage to 35.17 seconds but delayed the production-site build until the aggregate reached 41.06 seconds. Core count therefore does not justify copying an equally large worker limit. The process-bound coverage project contains exactly five suite files. Thirty-two forks crashed Node 24's CJS lexer twice, and a later 16-fork run reproduced the worker loss and invalid coverage result. The single Vitest invocation therefore uses threads for the broad inventory and reserves forks for suites that exercise process-global state, `process` APIs, or timing-sensitive process I/O. That narrow fork inventory includes the local bash process-plumbing suite and the pi-ai adapter suite because aggregate contention changed timing observations in both. These failures make deterministic coverage, not advertised cores, the upper bound on worker selection. diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md index f43712859d..0484548c76 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md @@ -26,7 +26,7 @@ Linux 主流程使用 3 个相互独立的 32 核作业。覆盖率单独运行 Windows 以一次 32 核环境设置同时承载阻塞性构建、生产网站和观测性的构建产物契约。重复的 lint、覆盖率和快照清单由 Linux 承担,因为在 Windows 上运行这些观测性副本会延长付费关键路径,却不会新增任何阻塞性平台契约。 -一次[分支头精确的全规格基准测试](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29908491351)在修正构建尽早启动逻辑前,对每种 Linux 池都运行了完整且未分片的主 Node 聚合流程: +一次分支头精确的全规格基准测试在修正构建尽早启动逻辑前,对每种 Linux 池都运行了完整且未分片的主 Node 聚合流程: | Linux 完整主流程 | 4 核 | 8 核 | 16 核 | 32 核 | 64 核 | 96 核 | |---|---:|---:|---:|---:|---:|---:| @@ -40,13 +40,13 @@ Windows 以一次 32 核环境设置同时承载阻塞性构建、生产网站 |---|---:|---:|---:|---:|---:|---:| | 活动耗时 | 152 秒 | 104 秒 | 104 秒 | 92 秒 | 103 秒 | 110 秒 | -Windows 仓库工作在超过 16 核后收益很小,但 32 核池可以让完整的外层清单同时启动。一次[重新定向的生产验证](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29907581119/attempts/2)在 173 秒内完成了单机 Windows 完整清单,其中包括覆盖率和快照回放,因此 Windows 继续采用合并执行方式。 +Windows 仓库工作在超过 16 核后收益很小,但 32 核池可以让完整的外层清单同时启动。一次重新定向的生产验证在 173 秒内完成了单机 Windows 完整清单,其中包括覆盖率和快照回放,因此 Windows 继续采用合并执行方式。 -客户端包依赖图增大后,缓存机制和调度器压力也成为实测工作负载的一部分。在[一次分支头精确的候选运行](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29912577681)中,Linux 的仓库门禁耗时 39 秒,完整作业耗时 69 秒;Windows 的仓库门禁耗时 117 秒,完整作业耗时 228 秒。Windows pnpm 缓存的 154 MB 归档下载耗时约 2 秒,但解压耗时 27 秒,随后安装耗时 23 秒,作业结束后的保存又耗时 14 秒。一次[无缓存的全规格运行轨迹](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29913033155)在 27 秒内完成了同一台 32 核 Windows 运行器上的安装。因此,未来若要启用大型运行器,需要测量完整作业,而不能只测门禁耗时。 +客户端包依赖图增大后,缓存机制和调度器压力也成为实测工作负载的一部分。在一次分支头精确的候选运行中,Linux 的仓库门禁耗时 39 秒,完整作业耗时 69 秒;Windows 的仓库门禁耗时 117 秒,完整作业耗时 228 秒。Windows pnpm 缓存的 154 MB 归档下载耗时约 2 秒,但解压耗时 27 秒,随后安装耗时 23 秒,作业结束后的保存又耗时 14 秒。一次无缓存的全规格运行轨迹在 27 秒内完成了同一台 32 核 Windows 运行器上的安装。因此,未来若要启用大型运行器,需要测量完整作业,而不能只测门禁耗时。 任何比较都必须计入主机设置。一个标准 Node 26 作业曾在总共 67 秒的耗时中,把 36 秒用在 `Set up job` 上;`actions/setup-node` 从托管 toolcache 找到 Node 后,仍花费 46.56 秒输出缓存的 Windows 环境详情。一个 Linux 候选作业还在注册 50 KB 的 Bubblewrap 包时耗时 18 秒,因为托管映像扫描了 202,507 个包数据库文件。[`scripts/prepare-ci-bubblewrap.sh`](../../../../scripts/prepare-ci-bubblewrap.sh) 改为验证固定版本的 payload 并将其解压到临时运行器目录,执行功能性隔离探针,并让这项准备工作与依赖安装重叠执行。 -内层与外层工作线程上限是相互独立的控制机制。一次[分支头精确、使用 32 个工作线程的 ESLint 实验](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29918329463)使 lint 耗时增至 52.28 秒、覆盖率耗时增至 42.71 秒;同一次运行中,一项适配器空闲超时测试失败。后来一次同时运行 8 项门禁的运行轨迹将覆盖率耗时降至 35.17 秒,但生产网站构建被延后,直到聚合流程耗时达到 41.06 秒时才完成。因此,不能仅凭核心数照搬同等规模的工作线程上限。 +内层与外层工作线程上限是相互独立的控制机制。一次分支头精确、使用 32 个工作线程的 ESLint 实验使 lint 耗时增至 52.28 秒、覆盖率耗时增至 42.71 秒;同一次运行中,一项适配器空闲超时测试失败。后来一次同时运行 8 项门禁的运行轨迹将覆盖率耗时降至 35.17 秒,但生产网站构建被延后,直到聚合流程耗时达到 41.06 秒时才完成。因此,不能仅凭核心数照搬同等规模的工作线程上限。 进程约束的覆盖率项目恰好包含 5 个套件文件。32 个 fork 曾两次导致 Node 24 的 CJS 词法分析器崩溃,后来一次使用 16 个 fork 的运行又复现了工作进程丢失和无效的覆盖率结果。因此,单次 Vitest 调用会对大范围测试清单使用线程,只为涉及进程全局状态、`process` API 或对时间敏感的进程 I/O 的套件保留 fork。这份有限的 fork 清单包括本地 bash 进程通路套件和 pi-ai 适配器套件,因为聚合争用改变了二者的时序观测结果。这些故障表明,选择工作线程数量时,上限取决于能否得到确定的覆盖率结果,而非标称核心数。 diff --git a/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.i18n.yaml index be725fc905..ac859f10dd 100644 --- a/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.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-07-26-briefed-minimal-translation-updates.md -2026-07-26-briefed-minimal-translation-updates.md: a47251376771165d0eb229aaa0fb7f63589d7d77 -2026-07-26-briefed-minimal-translation-updates.zh.md: c3c1b4e845b5a45acde55884b883b1dd1e570d77 +2026-07-26-briefed-minimal-translation-updates.md: afd990b7b63adfd0e66a4726975b678d044e7cad +2026-07-26-briefed-minimal-translation-updates.zh.md: dcdb253746041a7928ab7a544ae427293bc3f2de diff --git a/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md index a472513767..afd990b7b6 100644 --- a/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md +++ b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md @@ -12,7 +12,7 @@ The [bilingual pairing contract](2026-07-02-bilingual-docs-and-pairing-gate.md) Pair updates run on a generated briefing instead of the guidance corpus; only new pairs still run the whole-document workflow, which is unchanged. -- **`pnpm run gen-translation-brief [--apply] [pair...]`** ([scripts/gen-translation-brief.ts](../../../../scripts/gen-translation-brief.ts), assembly in [scripts/translation-brief.ts](../../../../scripts/translation-brief.ts)) prints, per out-of-sync pair, the authored side's diff from its recorded last-confirmed blob to the working tree plus the change mapped at the narrowest safely aligned granularity, deterministically widening on mapping failure: a change confined to the pair's byte-identical code fences is computed outright (`--apply` splices it into the counterpart and validates the result against the pairing gate's structural signature before writing); otherwise changed Markdown units (headings, paragraphs, table rows, list items, code fences, block quotes, HTML blocks, thematic breaks, link definitions — matched by container-scoped kind sequences) each carry their last-confirmed source, current source, and current counterpart text with line numbers; units that do not align fall back to depth-matched heading sections; and when sections do not align either, or both sides drifted, the briefing says so and withholds the mapping instead of guessing. Terminology rows are matched against the changed spans only (word-boundary English matching with plural inflections), and for Chinese targets the briefing tracks each relevant term's document-wide first occurrence — when an edit moves it, the vacated and receiving spans join the briefing with an explanatory note, since the 首次出现 annotation must move with it. The unit mapping, code splice, and first-occurrence mechanics adopt the planner design from the [incremental prompt-pipeline work](https://github.com/deepseek-harness/deepseek-harness/pull/684), whose provider-backed bake-off independently validated the same scope ladder for the automated pipeline. The briefing is the translator's whole working set; the full sources of truth remain the escalation path for decisions the briefing cannot answer. +- **`pnpm run gen-translation-brief [--apply] [pair...]`** ([scripts/gen-translation-brief.ts](../../../../scripts/gen-translation-brief.ts), assembly in [scripts/translation-brief.ts](../../../../scripts/translation-brief.ts)) prints, per out-of-sync pair, the authored side's diff from its recorded last-confirmed blob to the working tree plus the change mapped at the narrowest safely aligned granularity, deterministically widening on mapping failure: a change confined to the pair's byte-identical code fences is computed outright (`--apply` splices it into the counterpart and validates the result against the pairing gate's structural signature before writing); otherwise changed Markdown units (headings, paragraphs, table rows, list items, code fences, block quotes, HTML blocks, thematic breaks, link definitions — matched by container-scoped kind sequences) each carry their last-confirmed source, current source, and current counterpart text with line numbers; units that do not align fall back to depth-matched heading sections; and when sections do not align either, or both sides drifted, the briefing says so and withholds the mapping instead of guessing. Terminology rows are matched against the changed spans only (word-boundary English matching with plural inflections), and for Chinese targets the briefing tracks each relevant term's document-wide first occurrence — when an edit moves it, the vacated and receiving spans join the briefing with an explanatory note, since the 首次出现 annotation must move with it. The unit mapping, code splice, and first-occurrence mechanics adopt the planner design from the incremental prompt-pipeline work; its provider-backed bake-off independently validated the same scope ladder for the automated pipeline. The briefing is the translator's whole working set; the full sources of truth remain the escalation path for decisions the briefing cannot answer. - **The update path in [dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md)** consumes the briefing: mechanical (code-fence-only) changes are applied with `--apply`, no subagent; prose diffs go to a subagent whose prompt is the briefing, not the corpus; verification is clause-by-clause on the changed spans, not the whole document. - **The pairing gate takes pair arguments.** `verify-translation-pairing [pair...]` checks just the named pairs (any of a pair's three files, or the bare stem, names it); the corpus-wide sweep remains the no-argument form that `doc-sync` and CI run. `--write` now requires naming the confirmed pairs — bare `--write` refuses, and re-recording everything is an explicit `--write --all` — because the old bare form silently blessed every drifted pair in the tree, including ones the caller never looked at, and a prose-only drift would then stay green forever. Each record's comment names its own scoped command. Before recording, `--write` stores each side's exact bytes with `git hash-object -w --stdin` and pins the blob under a content-addressed local `refs/dsh/translation-pairing/snapshots/` ref; an uncommitted last-confirmed snapshot is therefore available to the briefing generator's later `git cat-file`, not merely named by a hash that Git cannot resolve or left vulnerable to garbage collection. diff --git a/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.zh.md b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.zh.md index c3c1b4e845..dcdb253746 100644 --- a/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.zh.md @@ -12,7 +12,7 @@ Status: implemented 配对更新基于生成的简报(briefing)运行,而非基于指导语料;只有新建配对仍走整篇文档工作流,后者保持不变。 -- **`pnpm run gen-translation-brief [--apply] [pair...]`**([scripts/gen-translation-brief.ts](../../../../scripts/gen-translation-brief.ts),组装逻辑在 [scripts/translation-brief.ts](../../../../scripts/translation-brief.ts))针对每个失去同步的配对,打印被改一侧从其记录在案的上次确认 blob 到当前工作区的 diff,并附上以能安全对齐的最窄粒度映射的这次改动,映射失败时粒度确定性地逐级放宽:仅落在配对中逐字节一致的围栏代码块内的改动会直接算出(`--apply` 会把它拼接进对侧文件,并在写入前用配对门禁的结构签名校验所得结果);否则,每个有改动的 Markdown 单元(标题、段落、表格行、列表项、围栏代码块、块引用、HTML 块、分隔线、链接定义;匹配依据是以容器为作用域的种类序列)都带上各自的上次确认源文、当前源文与当前对侧文本及行号;无法对齐的单元回退到按深度匹配的标题章节;当章节也无法对齐或两侧同时漂移时,简报会明说这一点并省略映射,而不是靠猜。术语表行只与改动块匹配(英文术语按词边界匹配,含复数变形);当目标侧是中文时,简报还会跟踪每个相关术语在整篇文档中的首次出现:一旦某次编辑使其移位,腾出的与接收的两处区间就会附一条解释性说明加入简报,因为「首次出现」括注必须随之移动。单元映射、代码拼接与首次出现机制采纳了[增量提示词流水线工作](https://github.com/deepseek-harness/deepseek-harness/pull/684)中的规划器设计;该项工作中接入提供方的对比评测,已为自动流水线独立验证了同一套范围阶梯。简报就是译者的全部工作集;简报回答不了的决策,仍以完整的真源文档作为升级求证路径。 +- **`pnpm run gen-translation-brief [--apply] [pair...]`**([scripts/gen-translation-brief.ts](../../../../scripts/gen-translation-brief.ts),组装逻辑在 [scripts/translation-brief.ts](../../../../scripts/translation-brief.ts))针对每个失去同步的配对,打印被改一侧从其记录在案的上次确认 blob 到当前工作区的 diff,并附上以能安全对齐的最窄粒度映射的这次改动,映射失败时粒度确定性地逐级放宽:仅落在配对中逐字节一致的围栏代码块内的改动会直接算出(`--apply` 会把它拼接进对侧文件,并在写入前用配对门禁的结构签名校验所得结果);否则,每个有改动的 Markdown 单元(标题、段落、表格行、列表项、围栏代码块、块引用、HTML 块、分隔线、链接定义;匹配依据是以容器为作用域的种类序列)都带上各自的上次确认源文、当前源文与当前对侧文本及行号;无法对齐的单元回退到按深度匹配的标题章节;当章节也无法对齐或两侧同时漂移时,简报会明说这一点并省略映射,而不是靠猜。术语表行只与改动块匹配(英文术语按词边界匹配,含复数变形);当目标侧是中文时,简报还会跟踪每个相关术语在整篇文档中的首次出现:一旦某次编辑使其移位,腾出的与接收的两处区间就会附一条解释性说明加入简报,因为「首次出现」括注必须随之移动。单元映射、代码拼接与首次出现机制采纳了增量提示词流水线工作的规划器设计;该项工作中接入提供方的对比评测,已为自动流水线独立验证了同一套范围阶梯。简报就是译者的全部工作集;简报回答不了的决策,仍以完整的真源文档作为升级求证路径。 - **[dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md) 中的更新路径**消费这份简报:机械类改动(只涉及围栏代码块)用 `--apply` 应用,不动用 subagent;行文类 diff 交给 subagent,其提示词就是简报本身,而非指导语料;核验只对改动块逐句进行,不覆盖整篇文档。 - **配对门禁接受配对参数。**`verify-translation-pairing [pair...]` 只检查被点名的配对(配对三个文件中的任意一个,或其裸词干,都能指代该配对);全语料扫描仍是 `doc-sync`(文档同步门禁)与 CI 运行的无参数形式。`--write` 现在要求点名已确认的配对:裸 `--write` 会拒绝执行,重新记录全部配对必须显式写 `--write --all`;原因是旧的裸形式会默默为树中每一个漂移的配对背书,包括调用者从未看过的那些,纯行文层面的漂移于是可以永远保持绿灯。每份记录的注释都写明针对该配对自身的按对命令。写下记录之前,`--write` 用 `git hash-object -w --stdin` 存入每一侧的精确字节,并在内容寻址的本地 `refs/dsh/translation-pairing/snapshots/` ref 下固定该 blob;未提交的上次确认快照因此能被简报生成器之后的 `git cat-file` 取回,而不只是留下一个 Git 无法解析的 hash 名称或暴露于垃圾回收。 diff --git a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml index 4786909a7f..19945a6580 100644 --- a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.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-07-27-wine-windows-gates-experiment.md -2026-07-27-wine-windows-gates-experiment.md: 640c8e455b1a35ea4ac83454227147b9979316dc -2026-07-27-wine-windows-gates-experiment.zh.md: 67f59a93d1b1fad98e36e6f9c51bc77abb9e3d07 +2026-07-27-wine-windows-gates-experiment.md: 1b01fe00dc1588482442a3cedc35eefa7fdb0975 +2026-07-27-wine-windows-gates-experiment.zh.md: b50739f5b63c5836248c94927037e38900abc84f diff --git a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md index 640c8e455b..1b01fe00dc 100644 --- a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md +++ b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md @@ -14,7 +14,7 @@ The question the experiment answered: can a plain Linux runner produce an equiva The required pull-request `windows` job in [ci.yml](../../../../.github/workflows/ci.yml) (`windows node 24 / wine blocking`) runs the blocking gate commands on `ubuntu-latest` under Wine with real Windows binaries: a checksum-verified win-x64 Node.js executes `tsc -b`, `tsdown`, and the VitePress production build, so the win32 branches of the toolchain — backslash path handling, `CreateProcess` spawn semantics, PE loading of `@esbuild/win32-x64`, and the rolldown/rollup MSVC `.node` addons — actually execute. The master `serial-windows` job is untouched: the complete native-kernel inventory, including the observational portability gates this lane does not run, still executes on real `windows-2025` on every master push. -Dependencies install natively on Linux with `supportedArchitectures` extended to win32-x64, which materializes the Windows platform packages in the same store; the cmd-shim layer is bypassed by invoking each tool's JavaScript entrypoint directly, the same processes `run-gates` ultimately spawns. `nodeLinker: hoisted` is load-bearing, not stylistic: the independent prototype in [PR #689](https://github.com/deepseek-harness/deepseek-harness/pull/689) kept pnpm's default isolated layout — including a faithful offline Windows-pnpm re-install over a Linux-prefetched store — and Windows Node under Wine still could not resolve `@esbuild/win32-x64` or load the koffi prebuild through the isolated symlink chain, failing before any repository gate ran. A flat layout with real files is what makes the gates reachable at all; #689's checksum pinning is adopted, while its Windows-pnpm-installs-the-tree goal is explicitly given up (the install contract stays Linux-tested here). +Dependencies install natively on Linux with `supportedArchitectures` extended to win32-x64, which materializes the Windows platform packages in the same store; the cmd-shim layer is bypassed by invoking each tool's JavaScript entrypoint directly, the same processes `run-gates` ultimately spawns. `nodeLinker: hoisted` is load-bearing, not stylistic: an independent prototype kept pnpm's default isolated layout — including a faithful offline Windows-pnpm re-install over a Linux-prefetched store — and Windows Node under Wine still could not resolve `@esbuild/win32-x64` or load the koffi prebuild through the isolated symlink chain, failing before any repository gate ran. A flat layout with real files is what makes the gates reachable at all; the prototype's checksum pinning is adopted, while its Windows-pnpm-installs-the-tree goal is explicitly given up (the install contract stays Linux-tested here). The lane holds the wall clock of the Linux CI jobs through four levers: the master-refreshed pnpm store cache (restore-only, same key as the Linux jobs), Wine provisioning (apt install, Windows Node download, `wineboot`) running concurrently with `pnpm install`, the two blocking surfaces running concurrently — the same shape `run-gates` gives them on native Windows — and an apt-archive cache keyed on the runner image, seeded from master by the `wine apt cache` job so every pull request restores from the default-branch scope. @@ -32,7 +32,7 @@ Measured on 2026-07-27, warm caches, pull-request trigger, standard 2-core `ubun **A full Windows guest under QEMU/KVM inside the Linux runner.** Real NT kernel, so full fidelity including case-insensitive NTFS and ConPTY — but tens of minutes of image download and unattended install before the first gate runs (40m19s measured end-to-end on the sibling experiment branch `exp/kvm-windows-ci`). Promotable only with disk-image caching that pressures the Actions cache budget. -**Windows pnpm performing the install under Wine ([PR #689](https://github.com/deepseek-harness/deepseek-harness/pull/689)).** The higher-fidelity variant of this same idea: MinGit and pnpm staged into the prefix, a Linux prefetch filling the store, then `pnpm install --offline` run by Windows Node so the install contract itself executes as win32. It reached the install but not the gates — Wine's networking could not reach the registry directly, and the isolated `node_modules` layout defeated resolution of the Windows platform packages even after a clean offline install. This lane trades that fidelity away (hoisted layout, Linux-side install) to reach the gates; the two records are complementary halves of the same verdict. +**Windows pnpm performing the install under Wine.** The higher-fidelity variant of this same idea: MinGit and pnpm staged into the prefix, a Linux prefetch filling the store, then `pnpm install --offline` run by Windows Node so the install contract itself executes as win32. It reached the install but not the gates — Wine's networking could not reach the registry directly, and the isolated `node_modules` layout defeated resolution of the Windows platform packages even after a clean offline install. This lane trades that fidelity away (hoisted layout, Linux-side install) to reach the gates; the two records are complementary halves of the same verdict. **Filesystem-semantics lanes on Linux (casefolded ext4, filename lint).** Catches the highest-frequency Windows breakage class for near-zero cost but proves nothing about win32 binaries. Explored as the sibling experiment branch `exp/casefold-windows-ci`; complementary to, not competitive with, this lane. diff --git a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md index 67f59a93d1..b50739f5b6 100644 --- a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md +++ b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md @@ -14,7 +14,7 @@ Pull request 的 Windows 通道旨在验证两个阻断性 win32 表面,即 wo [ci.yml](../../../../.github/workflows/ci.yml) 中必需的 pull request `windows` 作业(`windows node 24 / wine blocking`)在 `ubuntu-latest` 上通过 Wine 用真实 Windows 二进制运行阻断门禁命令:校验和验证过的 win-x64 Node.js 执行 `tsc -b`、`tsdown` 与 VitePress 生产构建,因此工具链的 win32 分支——反斜杠路径处理、`CreateProcess` 派生语义、`@esbuild/win32-x64` 的 PE 加载、以及 rolldown/rollup 的 MSVC `.node` 插件——都真正执行。master 的 `serial-windows` 作业原封不动:完整的原生内核清单,包括本通道不运行的观察性可移植性门禁,仍在每次 master push 时于真实 `windows-2025` 上执行。 -依赖在 Linux 上原生安装,`supportedArchitectures` 扩展到 win32-x64,使 Windows 平台包物化进同一个 store;通过直接调用各工具的 JavaScript 入口绕开 cmd-shim 层,这正是 `run-gates` 最终派生的那些进程。`nodeLinker: hoisted` 是承重的,不是风格问题:[PR #689](https://github.com/deepseek-harness/deepseek-harness/pull/689) 的独立原型保留了 pnpm 默认的 isolated 布局——包括在 Linux 预取的 store 上忠实地用 Windows pnpm 离线重装——而 Wine 下的 Windows Node 依然无法穿过 isolated 符号链接链解析 `@esbuild/win32-x64` 或加载 koffi 预编译产物,在任何仓库门禁运行前就失败了。扁平的真实文件布局才让门禁变得可达;本通道采纳了 #689 的校验和固定,同时明确放弃其「Windows pnpm 安装依赖树」的目标(安装契约在此仍由 Linux 侧验证)。 +依赖在 Linux 上原生安装,`supportedArchitectures` 扩展到 win32-x64,使 Windows 平台包物化进同一个 store;通过直接调用各工具的 JavaScript 入口绕开 cmd-shim 层,这正是 `run-gates` 最终派生的那些进程。`nodeLinker: hoisted` 是承重的,不是风格问题:一个独立原型保留了 pnpm 默认的 isolated 布局——包括在 Linux 预取的 store 上忠实地用 Windows pnpm 离线重装——而 Wine 下的 Windows Node 依然无法穿过 isolated 符号链接链解析 `@esbuild/win32-x64` 或加载 koffi 预编译产物,在任何仓库门禁运行前就失败了。扁平的真实文件布局才让门禁变得可达;本通道采纳了该原型的校验和固定,同时明确放弃其「Windows pnpm 安装依赖树」的目标(安装契约在此仍由 Linux 侧验证)。 该通道靠四个杠杆把墙钟时间保持在与 Linux CI 作业相当的水平:master 刷新的 pnpm store 缓存(只恢复,与 Linux 作业同键)、Wine 供给(apt 安装、Windows Node 下载、`wineboot`)与 `pnpm install` 并发运行、两个阻断表面并发运行——与 `run-gates` 在原生 Windows 上给它们的形状相同——以及按 runner 镜像为键的 apt 归档缓存,由 master 的 `wine apt cache` 作业播种,使每个 pull request 都能从默认分支作用域恢复。 @@ -32,7 +32,7 @@ Pull request 的 Windows 通道旨在验证两个阻断性 win32 表面,即 wo **在 Linux runner 内用 QEMU/KVM 跑完整 Windows 客户机。** 真实 NT 内核,保真度完整,包括大小写不敏感的 NTFS 与 ConPTY——但首个门禁运行前要花数十分钟下载镜像并做无人值守安装(兄弟实验分支 `exp/kvm-windows-ci` 实测端到端 40 分 19 秒)。只有配上会挤压 Actions 缓存预算的磁盘镜像缓存才可投入使用。 -**在 Wine 下由 Windows pnpm 执行安装([PR #689](https://github.com/deepseek-harness/deepseek-harness/pull/689))。** 同一想法的更高保真度变体:把 MinGit 与 pnpm 放进 prefix,用 Linux 预取填充 store,再由 Windows Node 运行 `pnpm install --offline`,让安装契约本身以 win32 身份执行。它到达了安装但没到达门禁——Wine 的网络无法直接访问 registry,且 isolated 的 `node_modules` 布局即便在干净的离线安装后也挫败了 Windows 平台包的解析。本通道牺牲这份保真度(hoisted 布局、Linux 侧安装)来换取门禁可达;两份记录是同一裁决互补的两半。 +**在 Wine 下由 Windows pnpm 执行安装。** 同一想法的更高保真度变体:把 MinGit 与 pnpm 放进 prefix,用 Linux 预取填充 store,再由 Windows Node 运行 `pnpm install --offline`,让安装契约本身以 win32 身份执行。它到达了安装但没到达门禁——Wine 的网络无法直接访问 registry,且 isolated 的 `node_modules` 布局即便在干净的离线安装后也挫败了 Windows 平台包的解析。本通道牺牲这份保真度(hoisted 布局、Linux 侧安装)来换取门禁可达;两份记录是同一裁决互补的两半。 **Linux 上的文件系统语义通道(casefold ext4、文件名 lint)。** 以近零成本捕获最高频的 Windows 故障类别,但对 win32 二进制什么也证明不了。作为兄弟实验分支 `exp/casefold-windows-ci` 探索;与本通道互补而非竞争。 diff --git a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml index 79ea52067d..77003539ac 100644 --- a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.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-07-31-installer-adopts-existing-checkout.md -2026-07-31-installer-adopts-existing-checkout.md: ff02fe837f2ad4deb3fb852f610f3cd3ff9a23d7 -2026-07-31-installer-adopts-existing-checkout.zh.md: 28816c80764acc0d4a2fcd13b3b8a38807021fd6 +2026-07-31-installer-adopts-existing-checkout.md: 3a213a6232e57f305910240505983421dcd288ad +2026-07-31-installer-adopts-existing-checkout.zh.md: 7cdcd5549fb2bca2a6cb11f0cf2867687565ae79 diff --git a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md index ff02fe837f..3a213a6232 100644 --- a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md +++ b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md @@ -46,6 +46,6 @@ A container adopting an outside clone is also no longer self-contained: deleting ## Testing -`scripts/install.sh` now has a real-shell PTY regression suite in `apps/cli/tests/install-script.spec.ts`, covering adoption and curl-style paths with stubbed dependencies. The installer's longer-term deletion in favor of pnpm/npx is tracked in [#1890](https://github.com/deepseek-harness/deepseek-harness/issues/1890). +`scripts/install.sh` now has a real-shell PTY regression suite in `apps/cli/tests/install-script.spec.ts`, covering adoption and curl-style paths with stubbed dependencies. Curl-style installs default to the public `deepseek-ai/deepseek-harness-sdk` source, while replacing the installer with pnpm/npx remains separate work. Verification was manual, through a throwaway harness driving the real script with a stubbed `pnpm`: adopting a standalone clone; adopting from a linked worktree into its existing container; an explicit `DSH_SOURCE` still opting back into cloning; a dirty tree adopting silently with no prompt or warning while its uncommitted file stays behind; a non-git checkout failing with guidance; and a `curl`-style clone install asserting the built layout, which is the regression that caught the unresolved-`REPO_ROOT` defect. The interactive path was exercised under tmux from a dirty checkout, confirming the run reaches the launcher with no adoption prompt and ends with `dsh` running from the new staging worktree while the original checkout keeps its branch and its uncommitted file. diff --git a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md index 28816c8076..7cdcd5549f 100644 --- a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md +++ b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md @@ -46,6 +46,6 @@ Status: implemented ## Testing -`scripts/install.sh` 现有一套位于 `apps/cli/tests/install-script.spec.ts` 的真实 shell PTY 回归测试,使用 stub 依赖覆盖接管路径和 curl 风格路径。[#1890](https://github.com/deepseek-harness/deepseek-harness/issues/1890) 跟踪安装器的长期删除工作,届时将改用 pnpm/npx。 +`scripts/install.sh` 现有一套位于 `apps/cli/tests/install-script.spec.ts` 的真实 shell PTY 回归测试,使用 stub 依赖覆盖接管路径和 curl 风格路径。curl 风格安装默认使用公开的 `deepseek-ai/deepseek-harness-sdk` 源,而以 pnpm/npx 替换安装器仍是另一项工作。 验证是手工完成的,通过一个一次性测试装置以打桩的`pnpm`驱动真实脚本:接管独立克隆;从 linked worktree 接管进其已有容器;显式`DSH_SOURCE`仍回到克隆路径;工作树不干净时静默接管、既不提示也不警告,且其未提交文件留在原处;非 git 检出失败并给出指引;以及`curl`式克隆安装断言所构建的布局——正是这项回归测试捕获了`REPO_ROOT`未解析的缺陷。交互路径在 tmux 下从一个不干净的检出走通,确认整个过程不出现接管提示即可到达启动器,最终`dsh`从新的 staging worktree 运行,而原检出保持其分支不变、未提交文件仍在。 diff --git a/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.i18n.yaml b/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.i18n.yaml index 32b51699e2..8ba8380bf1 100644 --- a/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.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-06-doc-site-carries-its-images.md -2026-08-06-doc-site-carries-its-images.md: 9109808874579b79d85c2e22b0987110f41ddc42 -2026-08-06-doc-site-carries-its-images.zh.md: d601112e8870150c363d8533e85ef86e7f3f8ffc +2026-08-06-doc-site-carries-its-images.md: 4078a9b6251cf67456590ae25602a9f288c88dc1 +2026-08-06-doc-site-carries-its-images.zh.md: a9afb138d45d1ab991963b997e408477cf88110b diff --git a/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.md b/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.md index 9109808874..4078a9b625 100644 --- a/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.md +++ b/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.md @@ -20,7 +20,7 @@ Only a regular file whose real path stays inside the repository is copied; anyth `docsSourceFiles()` reports the placed images alongside the Markdown, so the dev server's watcher re-projects when a screenshot is replaced instead of serving the previous copy until something touches the page. -`placeImage` is optional because `rewriteMarkdown` is also called directly by its spec, where no generated tree exists. Without it the old GitHub-raw behavior stands, which keeps that seam honest: the fallback is still the correct answer for a consumer that only rewrites text. +`placeImage` is optional because `rewriteMarkdown` is also called directly by its spec, where no generated tree exists. Without it the GitHub-raw fallback points at the public source home, which keeps that seam honest for a consumer that only rewrites text. Canonical Markdown keeps writing ordinary repository-relative image paths, so the same file renders on GitHub and on the site. No document carries a site-absolute URL to satisfy VitePress. @@ -36,7 +36,7 @@ Canonical Markdown keeps writing ordinary repository-relative image paths, so th Images in published documentation now work regardless of who is reading or whether the repository is public, and the site build has no runtime dependency on GitHub for them. The generated tree grows by one copy of each referenced image per locale — the four screenshots in the model-provider guide add roughly 270 KB per locale. -Images referenced from *unpublished* documents are untouched: they still resolve to GitHub raw, and still fail for a private repository. Nothing consumes them today, and a document that is not on the site has no site build to carry its assets. +Images referenced from *unpublished* documents are untouched. A text-only projection resolves them against the public source home; a document that is not on the site has no site build to carry its assets. ## Testing diff --git a/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.zh.md b/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.zh.md index d601112e88..a9afb138d4 100644 --- a/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.zh.md +++ b/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.zh.md @@ -20,7 +20,7 @@ Status: implemented `docsSourceFiles()` 会连同被安置的图片一起上报,于是替换截图时开发服务器的 watcher 会重新投影,而不是一直服务旧副本直到有人碰一下页面。 -`placeImage` 之所以可选,是因为 `rewriteMarkdown` 也被它自己的 spec 直接调用,而那里并不存在生成树。不传它时保持原有的 GitHub raw 行为,这也让该接缝保持诚实:对只改写文本的消费方而言,这个回退仍是正确答案。 +`placeImage` 之所以可选,是因为 `rewriteMarkdown` 也被它自己的 spec 直接调用,而那里并不存在生成树。不传它时,GitHub raw 回退会指向公开源主页;这让该 seam 对只改写文本的消费方保持诚实。 正本 Markdown 照旧写普通的仓库相对图片路径,因此同一份文件在 GitHub 上和站点上都能正常显示。没有任何文档为了迁就 VitePress 而写站内绝对 URL。 @@ -36,7 +36,7 @@ Status: implemented 已发布文档中的图片,现在无论谁在阅读、无论仓库是否公开都能显示,站点构建也不再为图片依赖 GitHub 的运行时可达性。生成树会为每个 locale 各增加一份被引用图片的副本——配置模型指南里的四张截图,每个 locale 约 270 KB。 -**未发布**文档引用的图片不受影响:它们仍解析到 GitHub raw,对私有仓库仍然失败。今天没有任何消费方用到它们,而不在站点上的文档也没有站点构建可以承载其资源。 +**未发布**文档引用的图片不受影响。纯文本投影会相对于公开源主页解析它们;不在站点上的文档没有站点构建可以承载其资源。 ## Testing diff --git a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml index b7d396e007..202a27ed1e 100644 --- a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.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/testing/2026-06-19-acp-snapshot-tests.md -2026-06-19-acp-snapshot-tests.md: 39d3b7a3f4699ea96262f43c63a7d60574ba064f -2026-06-19-acp-snapshot-tests.zh.md: 7dd3a3fa83682c35945314c7cd9531ca72bbb1fb +2026-06-19-acp-snapshot-tests.md: c7b95bd68027705b99d850d596405e56eea0dfca +2026-06-19-acp-snapshot-tests.zh.md: 43d43262684920cae5feedb5f2eb109db00f9f8c diff --git a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md index 39d3b7a3f4..c7b95bd680 100644 --- a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md +++ b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md @@ -80,6 +80,6 @@ Tool determinism comes from a generated cwd, scrubbed environment, fresh non-log ## Consequences -The tier adds reviewed per-scenario input, session, stdout, optional override, and optional workspace fixtures, plus one file for each distinct pinned prompt and tool-schema sequence. Workspace seeds are copied into the generated cwd for both record and replay. In return the tier provides deterministic keyless coverage through the real Loader and tool composition. Most retained scenarios exercise the assembled backend rather than ACP; the [automation-only ACP decision](../simplification/2026-07-23-acp-automation-only-protocol.md#snapshot-boundary) keeps that corpus here, while [#1970](https://github.com/deepseek-harness/deepseek-harness/issues/1970) tracks moving it to a transport-neutral headless suite without losing coverage. +The tier adds reviewed per-scenario input, session, stdout, optional override, and optional workspace fixtures, plus one file for each distinct pinned prompt and tool-schema sequence. Workspace seeds are copied into the generated cwd for both record and replay. In return the tier provides deterministic keyless coverage through the real Loader and tool composition. Most retained scenarios exercise the assembled backend rather than ACP; the [automation-only ACP decision](../simplification/2026-07-23-acp-automation-only-protocol.md#snapshot-boundary) keeps that corpus here until it can move to a transport-neutral headless suite without losing coverage. This Agent Note relates to but does not supersede the [proposed determinism Agent Note](../../proposed/testing/2026-06-11-deterministic-and-stress-testing.md): that proposal's "universal replay fixture" re-derives session *message history* after every test (an internal-consistency invariant), whereas these snapshots pin assembled behavior plus the external automation output. They are complementary until the backend corpus moves off ACP. diff --git a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md index 7dd3a3fa83..43d4326268 100644 --- a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md +++ b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md @@ -80,6 +80,6 @@ Status: implemented ## 后果 -该测试层为每个场景增加经过评审的输入、会话、stdout、可选 override 和可选 workspace fixture,并为每个不同的已固定提示词序列、每个不同的已固定工具 schema 序列各增加一个文件。记录与回放都会把 workspace seed 复制到生成的 cwd。作为回报,该层通过真实 Loader 和工具组合提供确定性的无密钥覆盖。保留下来的大多数场景测试的是组装后的后端而非 ACP;[仅面向自动化的 ACP 决策](../simplification/2026-07-23-acp-automation-only-protocol.md#snapshot-boundary)将该语料保留在此处,而 [#1970](https://github.com/deepseek-harness/deepseek-harness/issues/1970) 跟踪在不损失覆盖的情况下将其迁移到传输无关的 headless 套件。 +该测试层为每个场景增加经过评审的输入、会话、stdout、可选 override 和可选 workspace fixture,并为每个不同的已固定提示词序列、每个不同的已固定工具 schema 序列各增加一个文件。记录与回放都会把 workspace seed 复制到生成的 cwd。作为回报,该层通过真实 Loader 和工具组合提供确定性的无密钥覆盖。保留下来的大多数场景测试的是组装后的后端而非 ACP;[仅面向自动化的 ACP 决策](../simplification/2026-07-23-acp-automation-only-protocol.md#snapshot-boundary)将该语料保留在此处,直至它能够在不损失覆盖的情况下迁移到传输无关的 headless 套件。 本 Agent Note 与[拟议的确定性 Agent Note](../../proposed/testing/2026-06-11-deterministic-and-stress-testing.md)相关,但不取代它:该提案的“通用回放 fixture”在每次测试后重新派生会话*消息历史*(内部一致性不变量),而这些快照固定组装后的行为与外部自动化输出。在后端语料迁出 ACP 之前,两者相互补充。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 66b3a5399e..46ab8e892f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,8 +30,7 @@ env: jobs: - # https://github.com/deepseek-harness/deepseek-harness/issues/1967 tracks - # restoring the three hosted serial reference jobs before release. + # TODO(hosted-serial-ci): Re-enable the three hosted serial reference jobs before release. # The self-hosted standby remains active on every master push. # Three enterprise jobs isolate coverage, static analysis, and the diff --git a/docs/cordis-tutorial/01-first-plugin.i18n.yaml b/docs/cordis-tutorial/01-first-plugin.i18n.yaml index 4e829dcb8f..29e0266689 100644 --- a/docs/cordis-tutorial/01-first-plugin.i18n.yaml +++ b/docs/cordis-tutorial/01-first-plugin.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/01-first-plugin.md -01-first-plugin.md: c44e7f95fb11d5337ecfaf4251c8b2f2b9b14680 -01-first-plugin.zh.md: 9461884d312ad2e64af12fa42952a986e1ad5d8a +01-first-plugin.md: 4359dfe4883f12e9cb242cf3009827fd7864768c +01-first-plugin.zh.md: 62ccb7e5d37beb5b9636439e563cea6eaa1044a0 diff --git a/docs/cordis-tutorial/01-first-plugin.md b/docs/cordis-tutorial/01-first-plugin.md index c44e7f95fb..4359dfe488 100644 --- a/docs/cordis-tutorial/01-first-plugin.md +++ b/docs/cordis-tutorial/01-first-plugin.md @@ -92,4 +92,4 @@ One caveat worth knowing early: a config entry whose module cannot be **resolved Next: [Lifecycle and effects](02-lifecycle-and-effects.md) — what happens when a plugin unloads. -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) diff --git a/docs/cordis-tutorial/01-first-plugin.zh.md b/docs/cordis-tutorial/01-first-plugin.zh.md index 9461884d31..62ccb7e5d3 100644 --- a/docs/cordis-tutorial/01-first-plugin.zh.md +++ b/docs/cordis-tutorial/01-first-plugin.zh.md @@ -92,4 +92,4 @@ export function apply(ctx: Context) { 下一章:[生命周期与 effect](02-lifecycle-and-effects.md):插件卸载时会发生什么。 -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) diff --git a/docs/cordis-tutorial/02-lifecycle-and-effects.i18n.yaml b/docs/cordis-tutorial/02-lifecycle-and-effects.i18n.yaml index deed723c39..12793267e2 100644 --- a/docs/cordis-tutorial/02-lifecycle-and-effects.i18n.yaml +++ b/docs/cordis-tutorial/02-lifecycle-and-effects.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/02-lifecycle-and-effects.md -02-lifecycle-and-effects.md: f1b39e06e9d25c51ab2d76503025e2b6ffe90c73 -02-lifecycle-and-effects.zh.md: 2e98e3af6d2f2b1b9cbb8ea38559bc1ffbf7e43b +02-lifecycle-and-effects.md: 7b195b63a1e8730f27b9dd9af8af6a68a588cee9 +02-lifecycle-and-effects.zh.md: 4a3f83dedd5c95c7fcb5c1aebbbb8cb2e849b9cf diff --git a/docs/cordis-tutorial/02-lifecycle-and-effects.md b/docs/cordis-tutorial/02-lifecycle-and-effects.md index f1b39e06e9..7b195b63a1 100644 --- a/docs/cordis-tutorial/02-lifecycle-and-effects.md +++ b/docs/cordis-tutorial/02-lifecycle-and-effects.md @@ -95,4 +95,4 @@ One ordering caveat: disposers start in reverse registration order, but multiple Next: [Services](03-services.md) — how plugins share capabilities. -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) diff --git a/docs/cordis-tutorial/02-lifecycle-and-effects.zh.md b/docs/cordis-tutorial/02-lifecycle-and-effects.zh.md index 2e98e3af6d..4a3f83dedd 100644 --- a/docs/cordis-tutorial/02-lifecycle-and-effects.zh.md +++ b/docs/cordis-tutorial/02-lifecycle-and-effects.zh.md @@ -95,4 +95,4 @@ PENDING → LOADING → ACTIVE → UNLOADING → DISPOSED 下一章:[服务](03-services.md):插件如何共享功能。 -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) diff --git a/docs/cordis-tutorial/03-services.i18n.yaml b/docs/cordis-tutorial/03-services.i18n.yaml index b116270811..2849ed8858 100644 --- a/docs/cordis-tutorial/03-services.i18n.yaml +++ b/docs/cordis-tutorial/03-services.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/03-services.md -03-services.md: 5848132c6ad18338fa893954d45fc20005db6199 -03-services.zh.md: 0f599f082573364e6ad38278e1914d8faf67faa1 +03-services.md: 562b49ede0aa4cc1d58c4d6af7c7d5d1ebb2e4b1 +03-services.zh.md: 964f7e3654614d136b8765bb727f85a5a05587a8 diff --git a/docs/cordis-tutorial/03-services.md b/docs/cordis-tutorial/03-services.md index 5848132c6a..562b49ede0 100644 --- a/docs/cordis-tutorial/03-services.md +++ b/docs/cordis-tutorial/03-services.md @@ -95,4 +95,4 @@ Service names live in one flat namespace per application. Prefix or namespace yo Next: [Events](04-events.md) — communication without a shared service. -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) diff --git a/docs/cordis-tutorial/03-services.zh.md b/docs/cordis-tutorial/03-services.zh.md index 0f599f0825..964f7e3654 100644 --- a/docs/cordis-tutorial/03-services.zh.md +++ b/docs/cordis-tutorial/03-services.zh.md @@ -95,4 +95,4 @@ export function apply(ctx: Context) { 下一章:[事件](04-events.md):无需共享服务即可通信。 -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) diff --git a/docs/cordis-tutorial/04-events.i18n.yaml b/docs/cordis-tutorial/04-events.i18n.yaml index 6d21e5ff1b..e7dc182114 100644 --- a/docs/cordis-tutorial/04-events.i18n.yaml +++ b/docs/cordis-tutorial/04-events.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/04-events.md -04-events.md: 18f39dc1b693e5fb7e1793ec4b7dcac9cf24db95 -04-events.zh.md: 3fdafb50303f49dca179bcaea32db211a66241f6 +04-events.md: 28ccb85d657afaabb5c6b4b1e9b10d6cf8710918 +04-events.zh.md: f78c971dcd9674d2a256c41000b627aecb2a572a diff --git a/docs/cordis-tutorial/04-events.md b/docs/cordis-tutorial/04-events.md index 18f39dc1b6..28ccb85d65 100644 --- a/docs/cordis-tutorial/04-events.md +++ b/docs/cordis-tutorial/04-events.md @@ -141,4 +141,4 @@ The harness uses waterfalls for decisions that cooperating plugins may wrap or a Next: [Configuration](05-config.md) — plugin options from `cordis.yml`. -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) diff --git a/docs/cordis-tutorial/04-events.zh.md b/docs/cordis-tutorial/04-events.zh.md index 3fdafb5030..f78c971dcd 100644 --- a/docs/cordis-tutorial/04-events.zh.md +++ b/docs/cordis-tutorial/04-events.zh.md @@ -141,4 +141,4 @@ harness 使用 waterfall 处理协作插件可以包装或回答的决策:[`ag 下一章:[配置](05-config.md):来自 `cordis.yml` 的插件选项。 -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) diff --git a/docs/cordis-tutorial/05-config.i18n.yaml b/docs/cordis-tutorial/05-config.i18n.yaml index db300b1745..7db45165c4 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: 8d4043e33a58fc425d82d9846ff82473bcdef4c1 -05-config.zh.md: e9463bd34e9c72dbae7b1ceb9907e35edf7b773b +05-config.md: 834bb140cc1ff976acc8f21c8f54a7fb02636eac +05-config.zh.md: f5cc6ac1ca4fa02eba6a1b015b9f6ae3b1a925fc diff --git a/docs/cordis-tutorial/05-config.md b/docs/cordis-tutorial/05-config.md index 8d4043e33a..834bb140cc 100644 --- a/docs/cordis-tutorial/05-config.md +++ b/docs/cordis-tutorial/05-config.md @@ -81,4 +81,4 @@ The loader used in this repo supports a `!!js` tag for config values that must b Next: [Composition and HMR](06-composition-and-hmr.md) — treating `cordis.yml` as the application. -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) diff --git a/docs/cordis-tutorial/05-config.zh.md b/docs/cordis-tutorial/05-config.zh.md index e9463bd34e..f5cc6ac1ca 100644 --- a/docs/cordis-tutorial/05-config.zh.md +++ b/docs/cordis-tutorial/05-config.zh.md @@ -81,4 +81,4 @@ ValidationError: invalid config: 下一章:[组合与 HMR(热模块替换)](06-composition-and-hmr.md):将 `cordis.yml` 视为应用。 -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) diff --git a/docs/cordis-tutorial/06-composition-and-hmr.i18n.yaml b/docs/cordis-tutorial/06-composition-and-hmr.i18n.yaml index f7abfca742..44b59db26a 100644 --- a/docs/cordis-tutorial/06-composition-and-hmr.i18n.yaml +++ b/docs/cordis-tutorial/06-composition-and-hmr.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/06-composition-and-hmr.md -06-composition-and-hmr.md: 66d6a9d93fe39baa881940ba32388979e2678505 -06-composition-and-hmr.zh.md: 7c0a94b0abcc0f153f59391fd009f1e0b40500e5 +06-composition-and-hmr.md: f138918a2d217ed98fdfd4e56dffddc14e3397f0 +06-composition-and-hmr.zh.md: a678e86735c6d9e3b4f3cd0dfa46a2a64079c762 diff --git a/docs/cordis-tutorial/06-composition-and-hmr.md b/docs/cordis-tutorial/06-composition-and-hmr.md index 66d6a9d93f..f138918a2d 100644 --- a/docs/cordis-tutorial/06-composition-and-hmr.md +++ b/docs/cordis-tutorial/06-composition-and-hmr.md @@ -110,4 +110,4 @@ needs-timer is PENDING — a required service is missing Next: [Into the harness](07-into-the-harness.md) — the same patterns against real harness services. -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) diff --git a/docs/cordis-tutorial/06-composition-and-hmr.zh.md b/docs/cordis-tutorial/06-composition-and-hmr.zh.md index 7c0a94b0ab..a678e86735 100644 --- a/docs/cordis-tutorial/06-composition-and-hmr.zh.md +++ b/docs/cordis-tutorial/06-composition-and-hmr.zh.md @@ -110,4 +110,4 @@ needs-timer is PENDING — a required service is missing 下一章:[进入 harness](07-into-the-harness.md):把相同模式用于真实的 harness 服务。 -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) diff --git a/docs/cordis-tutorial/07-into-the-harness.i18n.yaml b/docs/cordis-tutorial/07-into-the-harness.i18n.yaml index 5faa0bf213..f3dde47f3a 100644 --- a/docs/cordis-tutorial/07-into-the-harness.i18n.yaml +++ b/docs/cordis-tutorial/07-into-the-harness.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/07-into-the-harness.md -07-into-the-harness.md: 6ec42c50fe5059955734fe7bc46117538dafaffc -07-into-the-harness.zh.md: 903adb903aa4c4355b92eb34e89f218a0295767c +07-into-the-harness.md: e02f8f8d55b3fbe9087d46f8f50baeecb592c1c6 +07-into-the-harness.zh.md: 5f770267e8f3db04cd9cb0e92b6a6278cf05d5e4 diff --git a/docs/cordis-tutorial/07-into-the-harness.md b/docs/cordis-tutorial/07-into-the-harness.md index 6ec42c50fe..e02f8f8d55 100644 --- a/docs/cordis-tutorial/07-into-the-harness.md +++ b/docs/cordis-tutorial/07-into-the-harness.md @@ -104,4 +104,4 @@ Where to go next: - The generated [services](../cordis-catalog/services.md) and [events](../cordis-catalog/events.md) catalogs — everything you can inject and listen to. - [Architecture](../architecture.md) — the system map these plugins live in. -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) diff --git a/docs/cordis-tutorial/07-into-the-harness.zh.md b/docs/cordis-tutorial/07-into-the-harness.zh.md index 903adb903a..5f770267e8 100644 --- a/docs/cordis-tutorial/07-into-the-harness.zh.md +++ b/docs/cordis-tutorial/07-into-the-harness.zh.md @@ -104,4 +104,4 @@ logger 会先触发:`tools/result` 在结果物化过程中发出,发生在 - 生成的[服务](../cordis-catalog/services.md)与[事件](../cordis-catalog/events.md)目录:可以注入和监听的所有内容。 - [架构](../architecture.md):这些插件所处的系统地图。 -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) diff --git a/docs/cordis-tutorial/index.i18n.yaml b/docs/cordis-tutorial/index.i18n.yaml index 256bc4f629..496a3fffa5 100644 --- a/docs/cordis-tutorial/index.i18n.yaml +++ b/docs/cordis-tutorial/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/cordis-tutorial/index.md -index.md: af622ad4e35829c6283c40f1b0019d7959dac973 -index.zh.md: 0b7684a9532a1efdcc3ea2d067da23852d146e2f +index.md: a20976706f520416236ca759ee33649d1601eaa9 +index.zh.md: f6989521d4b7dffac6114867cc12371af4e4316f diff --git a/docs/cordis-tutorial/index.md b/docs/cordis-tutorial/index.md index af622ad4e3..a20976706f 100644 --- a/docs/cordis-tutorial/index.md +++ b/docs/cordis-tutorial/index.md @@ -13,7 +13,7 @@ If you want the condensed concept reference instead of a walkthrough, read the [ You need a clone of this repository with dependencies installed — the [quick start](../user/guide/quickstart.md) covers prerequisites. No API key is needed for this tutorial; every example runs keylessly. ```sh -git clone https://github.com/deepseek-harness/deepseek-harness.git +git clone https://github.com/deepseek-ai/deepseek-harness-sdk.git cd deepseek-harness pnpm install ``` @@ -55,4 +55,4 @@ The examples use three TypeScript features beyond ordinary modern JavaScript: Chapter 5 also uses an `interface` to describe a configuration object's fields and a generic type such as `Schema` to say which object shape a schema validates. You can copy those declarations as shown; the surrounding text explains what each one connects. -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) diff --git a/docs/cordis-tutorial/index.zh.md b/docs/cordis-tutorial/index.zh.md index 0b7684a953..f6989521d4 100644 --- a/docs/cordis-tutorial/index.zh.md +++ b/docs/cordis-tutorial/index.zh.md @@ -13,7 +13,7 @@ Cordis 是 DeepSeek Harness SDK 底层的插件框架:它是一个小型运行 你需要克隆本仓库并安装依赖,具体前置条件见[快速入门](../user/guide/quickstart.md)。本教程不需要 API 密钥;所有示例均可在无密钥环境中运行。 ```sh -git clone https://github.com/deepseek-harness/deepseek-harness.git +git clone https://github.com/deepseek-ai/deepseek-harness-sdk.git cd deepseek-harness pnpm install ``` @@ -55,4 +55,4 @@ node --import tsx ../../vendor/cordis/bin.js 第 5 章还会使用 `interface` 描述配置对象的字段,并使用 `Schema` 这类泛型表示 schema 所校验的对象形状。你可以直接照写这些声明;周围的正文会解释每项声明连接了什么。 -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) diff --git a/docs/user/guide/quickstart.i18n.yaml b/docs/user/guide/quickstart.i18n.yaml index 27592cdb74..aefb76991f 100644 --- a/docs/user/guide/quickstart.i18n.yaml +++ b/docs/user/guide/quickstart.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/quickstart.md -quickstart.md: 72cc5a52c33faf81098f747799692b384fcd5f1a -quickstart.zh.md: ebb831c1e1bec02117c78a4c2426a84af3bb9478 +quickstart.md: 8b84017ad33bf02579891bc4dcf83eaf7ec39022 +quickstart.zh.md: dfb24f8fa194866908406c709d059bf1f6595d59 diff --git a/docs/user/guide/quickstart.md b/docs/user/guide/quickstart.md index 72cc5a52c3..8b84017ad3 100644 --- a/docs/user/guide/quickstart.md +++ b/docs/user/guide/quickstart.md @@ -19,7 +19,7 @@ pnpm -v ## Step 1: install and configure the API key ```sh -git clone https://github.com/deepseek-harness/deepseek-harness.git +git clone https://github.com/deepseek-ai/deepseek-harness-sdk.git cd deepseek-harness pnpm install ``` diff --git a/docs/user/guide/quickstart.zh.md b/docs/user/guide/quickstart.zh.md index ebb831c1e1..dfb24f8fa1 100644 --- a/docs/user/guide/quickstart.zh.md +++ b/docs/user/guide/quickstart.zh.md @@ -19,7 +19,7 @@ pnpm -v ## 第一步:安装并配置 API key ```sh -git clone https://github.com/deepseek-harness/deepseek-harness.git +git clone https://github.com/deepseek-ai/deepseek-harness-sdk.git cd deepseek-harness pnpm install ``` diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index b2fd25eb4e..386926e51b 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -90,8 +90,8 @@ async function prepareFsSearchWorkspace(cwd: string): Promise { } } -// https://github.com/deepseek-harness/deepseek-harness/issues/1970 tracks moving -// backend/product scenarios to headless while retaining ACP protocol contracts here. +// TODO(acp-snapshot-ownership): Move backend/product scenarios to headless while +// retaining ACP protocol contracts here. function fixtureRecords(name: string): unknown[] { return readFileSync(join(SNAPSHOTS_DIR, name, 'session.jsonl'), 'utf8') diff --git a/examples/headless-agent/tests/compaction.e2e.ts b/examples/headless-agent/tests/compaction.e2e.ts index fdae11d2c7..39d054a2f5 100644 --- a/examples/headless-agent/tests/compaction.e2e.ts +++ b/examples/headless-agent/tests/compaction.e2e.ts @@ -10,8 +10,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. - * A keyless assembled snapshot with an explicit summarization replay override - * is tracked in https://github.com/deepseek-harness/deepseek-harness/issues/1971. + * TODO(compaction-snapshot): Add a keyless assembled snapshot with an explicit + * summarization replay override. */ let workdir: string | undefined diff --git a/examples/mcp-memory/README.i18n.yaml b/examples/mcp-memory/README.i18n.yaml index f89035cfcc..7e8e5248e4 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: 023e6aefce0e78cbbf52620426376e1dd0a6b8cf -README.zh.md: 44ace680cd583f41903437a69c62e30817308ba2 +README.md: 792bb31b668b427c8734286878a9ec98071190d8 +README.zh.md: 51020f5288c4fbd245914280b8e7e4772e8cad69 diff --git a/examples/mcp-memory/README.md b/examples/mcp-memory/README.md index 023e6aefce..792bb31b66 100644 --- a/examples/mcp-memory/README.md +++ b/examples/mcp-memory/README.md @@ -36,7 +36,7 @@ Without a repository checkout, download the selected overlay directly: mkdir -p "${DSH_HOME:-$HOME/.dsh}" curl --fail --location \ --output "${DSH_HOME:-$HOME/.dsh}/memory.cordis.yml" \ - https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/examples/mcp-memory/memorix.cordis.yml + https://raw.githubusercontent.com/deepseek-ai/deepseek-harness-sdk/master/examples/mcp-memory/memorix.cordis.yml dsh web --patch "${DSH_HOME:-$HOME/.dsh}/memory.cordis.yml" ``` diff --git a/examples/mcp-memory/README.zh.md b/examples/mcp-memory/README.zh.md index 44ace680cd..51020f5288 100644 --- a/examples/mcp-memory/README.zh.md +++ b/examples/mcp-memory/README.zh.md @@ -36,7 +36,7 @@ dsh web --patch "$PWD/examples/mcp-memory/memorix.cordis.yml" mkdir -p "${DSH_HOME:-$HOME/.dsh}" curl --fail --location \ --output "${DSH_HOME:-$HOME/.dsh}/memory.cordis.yml" \ - https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/examples/mcp-memory/memorix.cordis.yml + https://raw.githubusercontent.com/deepseek-ai/deepseek-harness-sdk/master/examples/mcp-memory/memorix.cordis.yml dsh web --patch "${DSH_HOME:-$HOME/.dsh}/memory.cordis.yml" ``` diff --git a/package.json b/package.json index b1ad6853fc..eaa1025974 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "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-public-repository-links": "tsx scripts/verify-public-repository-links.ts", "verify-doc-refs": "tsx scripts/verify-doc-refs.ts", "verify-package-paths": "tsx scripts/verify-package-paths.ts", "verify-config-source-ownership": "tsx scripts/verify-config-source-ownership.ts", diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml index 5225ba9d8c..30660e1032 100644 --- a/packages/llm/llm/README.i18n.yaml +++ b/packages/llm/llm/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm/README.md -README.md: 956bfa112d6fe50c35359cebdf3710064da8c130 -README.zh.md: 42f2da18089e7dcfc9acb95076ab8786c798444b +README.md: ddbc2ea482ca0848fb0ee0813839cf5ff1829bcc +README.zh.md: 7a3b615d134e27d7c9892d6f411066d89938175b diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index 956bfa112d..ddbc2ea482 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -97,5 +97,5 @@ Pass-through; the registry preserves the assembled request prefix, while the sel - **`GenerateOptions` sampling is `temperature`/`maxTokens`/`stop` only** — no `tool_choice`, `top_p`, or penalty fields; the vocabulary grows when a producer lands ([dropped inert knobs](../../../.agents/notes/archived/simplification/2026-07-04-drop-inert-request-knobs.md)). - **Producer-gated variants stay out until produced** — `prefill`, per-tool `strict`, block `cache` hints, and the `agent` message-source variant were pruned as producerless ([Agent Note](../../../.agents/notes/archived/simplification/2026-07-04-prune-producerless-vocabulary-variants.md)). - **`BlockAssembler` handles core block kinds only** — a plugin-added block type whose stream is never closed by `block-end` makes `blocks()` throw. -- **`APP_IDENTITY.url` names a repository that does not exist yet** — [#1972](https://github.com/deepseek-harness/deepseek-harness/issues/1972) tracks making the public home reachable before release. +- **`APP_IDENTITY.url` names a repository that does not exist yet** — the public home must be reachable before release. - **`GenerateOptions.sessionId` is a locally-declared brand** — importing dsh-session's `SessionId` would cycle; a future ids-owning package would dissolve the workaround. diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md index 42f2da1808..7a3b615d13 100644 --- a/packages/llm/llm/README.zh.md +++ b/packages/llm/llm/README.zh.md @@ -97,5 +97,5 @@ - **`GenerateOptions` 采样只包含 `temperature`/`maxTokens`/`stop`**:没有 `tool_choice`、`top_p` 或 penalty 字段;有产生方落地时词汇才会增长(见 [已删除惰性旋钮](../../../.agents/notes/archived/simplification/2026-07-04-drop-inert-request-knobs.md))。 - **受产生方约束的变体在实际产生前不会加入**:`prefill`、每工具 `strict`、块 `cache` 提示与 `agent` 消息源变体因没有产生方而被剪除(见 [Agent Note](../../../.agents/notes/archived/simplification/2026-07-04-prune-producerless-vocabulary-variants.md))。 - **`BlockAssembler` 只处理核心块类型**:如果插件添加块类型的流从未由 `block-end` 关闭,`blocks()` 会抛出异常。 -- **`APP_IDENTITY.url` 指向一个尚不存在的仓库**:[#1972](https://github.com/deepseek-harness/deepseek-harness/issues/1972) 跟踪在首次发布前让该公开主页可访问。 +- **`APP_IDENTITY.url` 指向一个尚不存在的仓库**:该公开主页必须在首次发布前可访问。 - **`GenerateOptions.sessionId` 是本地声明的品牌类型**:导入 dsh-session 的 `SessionId` 会产生循环;未来拥有 id 的包可以消除该权宜之计。 diff --git a/packages/llm/llm/src/attribution.ts b/packages/llm/llm/src/attribution.ts index b9375b6ef9..79cef011de 100644 --- a/packages/llm/llm/src/attribution.ts +++ b/packages/llm/llm/src/attribution.ts @@ -40,8 +40,7 @@ export interface AppIdentity { export const APP_IDENTITY: AppIdentity = { product: 'deepseek-harness', version, - // The public-home release blocker is tracked in - // https://github.com/deepseek-harness/deepseek-harness/issues/1972. + // TODO(public-home): Ensure this public source repository exists before release. url: 'https://github.com/deepseek-ai/deepseek-harness-sdk', } diff --git a/packages/llm/llm/src/call-config.ts b/packages/llm/llm/src/call-config.ts index 2daa6d1a4c..848654ad3b 100644 --- a/packages/llm/llm/src/call-config.ts +++ b/packages/llm/llm/src/call-config.ts @@ -12,6 +12,8 @@ import type { ReasoningEffortId } from './brand.ts' /** Process-local identities of request objects assembled by dsh-agent-loop. */ const AGENT_LOOP_REQUESTS = new WeakSet() +// TODO(call-config-shape): Revisit which fields are epoch-level for cache reuse +// and where provider-specific request options belong. /** * Provider, model, reasoning effort, and sampling scalars of one conversation's * requests. Every field maps 1:1 onto the same-named `GenerateOptions` field; diff --git a/packages/sdk/telemetry/README.i18n.yaml b/packages/sdk/telemetry/README.i18n.yaml index 987dc8197c..1604b40bf4 100644 --- a/packages/sdk/telemetry/README.i18n.yaml +++ b/packages/sdk/telemetry/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/sdk/telemetry/README.md -README.md: c9f66a2415c91b75105b0ed025470da234b2523d -README.zh.md: bfb154e4c7c017292b5479e50ff376cbb9470682 +README.md: c87735a93e7659f2913f4dd325176a8ae40cf29b +README.zh.md: 24d6e72d988f94cdbc6aa01607edbfc105213267 diff --git a/packages/sdk/telemetry/README.md b/packages/sdk/telemetry/README.md index c9f66a2415..c87735a93e 100644 --- a/packages/sdk/telemetry/README.md +++ b/packages/sdk/telemetry/README.md @@ -14,7 +14,7 @@ Launcher-side telemetry primitives for the dsh-sdk toolchain. This is a plain li Consent is carried by the telemetry entry in `cordis.yml`, so disabling telemetry is disabling that entry. Telemetry reports by default and is off only when a present telemetry entry is explicitly `disabled`: a missing `cordis.yml` (first `create`), an enabled entry, or a `cordis.yml` with no telemetry entry all report. `DO_NOT_TRACK`/CI always deny. The no-config and absent-entry defaults are configurable on `ConsentResolver`. -The collection endpoint is a fixed constant (`DSH_TELEMETRY_ENDPOINT`); [#1973](https://github.com/deepseek-harness/deepseek-harness/issues/1973) tracks deploying the service and replacing its fail-safe `.invalid` placeholder before release. +The collection endpoint is a fixed constant (`DSH_TELEMETRY_ENDPOINT`); its fail-safe `.invalid` placeholder must be replaced with the real endpoint before release. ## Model Experience @@ -26,5 +26,5 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **Placeholder endpoint** — `DSH_TELEMETRY_ENDPOINT` points at `.invalid` until the service tracked in [#1973](https://github.com/deepseek-harness/deepseek-harness/issues/1973) is ready. +- **Placeholder endpoint** — `DSH_TELEMETRY_ENDPOINT` points at `.invalid` until the real endpoint is set. - **Redaction is heuristic** — a conservative backstop, not a guarantee; secrets belong in `.env`, which is never read or reported. diff --git a/packages/sdk/telemetry/README.zh.md b/packages/sdk/telemetry/README.zh.md index bfb154e4c7..24d6e72d98 100644 --- a/packages/sdk/telemetry/README.zh.md +++ b/packages/sdk/telemetry/README.zh.md @@ -14,7 +14,7 @@ Consent 由 `cordis.yml` 中的 telemetry 配置项承载,因此禁用 telemetry 就是禁用该配置项。telemetry 默认上报,只有已经存在的 telemetry 配置项被显式设为 `disabled` 时才关闭:缺少 `cordis.yml`(首次 `create`)、配置项已启用,或 `cordis.yml` 中没有 telemetry 配置项时都会上报。`DO_NOT_TRACK`/CI 始终拒绝。无配置与缺少配置项的默认值可以通过 `ConsentResolver` 配置。 -收集端点是固定常量(`DSH_TELEMETRY_ENDPOINT`);[#1973](https://github.com/deepseek-harness/deepseek-harness/issues/1973) 跟踪服务部署,以及发布前将作为安全兜底的 `.invalid` 占位值替换为真实端点。 +收集端点是固定常量(`DSH_TELEMETRY_ENDPOINT`);发布前必须将作为安全兜底的 `.invalid` 占位值替换为真实端点。 ## 模型体验 @@ -26,5 +26,5 @@ Consent 由 `cordis.yml` 中的 telemetry 配置项承载,因此禁用 telemet ## 已知限制与暂缓事项 -- **占位端点**:`DSH_TELEMETRY_ENDPOINT` 指向 `.invalid`,直至 [#1973](https://github.com/deepseek-harness/deepseek-harness/issues/1973) 跟踪的服务就绪。 +- **占位端点**:`DSH_TELEMETRY_ENDPOINT` 指向 `.invalid`,直到设置真实端点。 - **脱敏依赖启发式规则**:这只是保守后备,不是保证;密钥应存放于 `.env`,而该文件绝不会被读取或上报。 diff --git a/packages/sdk/telemetry/src/reporter.ts b/packages/sdk/telemetry/src/reporter.ts index 3ad7b4b62e..b7ac75a23f 100644 --- a/packages/sdk/telemetry/src/reporter.ts +++ b/packages/sdk/telemetry/src/reporter.ts @@ -17,10 +17,10 @@ import { SecretRedactor } from './secret-redactor.ts' /** * Fail-safe placeholder collection endpoint. The `.invalid` TLD guarantees - * delivery fails harmlessly until the service tracked in - * https://github.com/deepseek-harness/deepseek-harness/issues/1973 is ready. - * This is a fixed protocol constant, not a deployment tunable. + * delivery fails harmlessly until a collector is deployed. This is a fixed + * protocol constant, not a deployment tunable. */ +// TODO(telemetry-endpoint): Replace the placeholder before release. export const DSH_TELEMETRY_ENDPOINT = 'https://telemetry.example.invalid/v1/dsh-sdk' /** Wire-envelope schema version; bump on any incompatible body change. */ diff --git a/scripts/install.sh b/scripts/install.sh index 59184d91ab..f290782892 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1,7 +1,7 @@ #!/bin/sh # dsh one-line installer. # -# curl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh +# curl -fsSL https://raw.githubusercontent.com/deepseek-ai/deepseek-harness-sdk/master/scripts/install.sh | sh # # It clones the harness under ~/.dsh/source (the master clone at # ~/.dsh/source/master), adds a per-install staging worktree at @@ -50,7 +50,7 @@ set -eu DSH_REF=${DSH_REF:-master} -DSH_REPO=${DSH_REPO:-https://github.com/deepseek-harness/deepseek-harness.git} +DSH_REPO=${DSH_REPO:-https://github.com/deepseek-ai/deepseek-harness-sdk.git} # DSH_SOURCE is the staging-worktree container and the default home of `current`. # DSH_MASTER names the main clone: clone mode defaults it inside DSH_SOURCE, # while adoption discovers an existing clone anywhere on disk. Remember whether diff --git a/scripts/project-doc-site.spec.ts b/scripts/project-doc-site.spec.ts index 6770381526..89e417558c 100644 --- a/scripts/project-doc-site.spec.ts +++ b/scripts/project-doc-site.spec.ts @@ -104,7 +104,7 @@ describe('rewriteMarkdown', () => { repositoryRef: 'abc123', })).toBe( '[B](./reference/b.md#part) ' - + '[source](https://github.com/deepseek-harness/deepseek-harness/blob/abc123/packages/tool.ts#L2) ' + + '[source](https://github.com/deepseek-ai/deepseek-harness-sdk/blob/abc123/packages/tool.ts#L2) ' + '[web](https://example.com)\n', ) }) @@ -130,7 +130,7 @@ describe('rewriteMarkdown', () => { pages, repoRoot: root, repositoryRef: 'abc123', - })).toBe('![logo](https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/abc123/packages/logo.svg)\n') + })).toBe('![logo](https://raw.githubusercontent.com/deepseek-ai/deepseek-harness-sdk/abc123/packages/logo.svg)\n') }) it('hands an image to the placer and uses the URL it returns', () => { @@ -209,7 +209,7 @@ describe('rewriteMarkdown', () => { repositoryRef: 'abc123', })).toBe( '[title](./reference/b.md "b.md") ' - + '[escaped](https://github.com/deepseek-harness/deepseek-harness/blob/abc123/docs/x(y).md)\n', + + '[escaped](https://github.com/deepseek-ai/deepseek-harness-sdk/blob/abc123/docs/x(y).md)\n', ) }) diff --git a/scripts/project-doc-site.ts b/scripts/project-doc-site.ts index 02a64b023a..e3397237a3 100644 --- a/scripts/project-doc-site.ts +++ b/scripts/project-doc-site.ts @@ -15,7 +15,7 @@ import { gfm } from 'micromark-extension-gfm' import type { Nodes } from 'mdast' import { docsPages, type DocsLocale, type DocsPage } from '../website/docs.ts' -const REPOSITORY_URL = 'https://github.com/deepseek-harness/deepseek-harness' +const REPOSITORY_URL = 'https://github.com/deepseek-ai/deepseek-harness-sdk' const root = resolve(import.meta.dirname, '..') const generatedRoot = resolve(root, 'website/.generated') @@ -203,7 +203,7 @@ function githubTarget( image: boolean, ): string { const path = repoPath(absPath, repoRoot) - if (image) return `https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/${repositoryRef}/${path}${suffix}` + if (image) return `https://raw.githubusercontent.com/deepseek-ai/deepseek-harness-sdk/${repositoryRef}/${path}${suffix}` const kind = lstatSync(absPath).isDirectory() ? 'tree' : 'blob' const lineSuffix = line === undefined ? suffix : `#L${line}` return `${REPOSITORY_URL}/${kind}/${repositoryRef}/${path}${lineSuffix}` diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index 84eeeb10bb..6979fb894d 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -77,6 +77,12 @@ describe('gate graph validation', () => { await expect(runGates(subject, subject.length, execute)).resolves.toHaveLength(subject.length) }) + it('keeps the public repository link policy in the documentation gate', () => { + const ids = withPnpmEntrypoint(() => gatesForMode('doc-sync').map(subject => subject.id)) + + expect(ids).toContain('public-repository-links') + }) + it.each([ ['empty', [], /gate graph has no gates/], ['duplicate ids', [gate('same'), gate('same')], /duplicate gate id "same"/], diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index c1c3e1699c..30288170f9 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -572,6 +572,7 @@ function docSyncLeafGates(options: { pnpmScript('scoped-events', 'verify-scoped-events', { label: 'scoped events' }), pnpmScript('markdown-wrap', 'verify-md-wrap', { label: 'markdown wrap' }), pnpmScript('markdown-links', 'verify-md-links', { label: 'markdown links' }), + pnpmScript('public-repository-links', 'verify-public-repository-links', { label: 'public repository 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' }), diff --git a/scripts/verify-public-repository-links.spec.ts b/scripts/verify-public-repository-links.spec.ts new file mode 100644 index 0000000000..b05dcb65d1 --- /dev/null +++ b/scripts/verify-public-repository-links.spec.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' +import { findInternalRepositoryReferences } from './verify-public-repository-links.ts' + +describe('public repository link policy', () => { + it('rejects the internal remote and accepts the public home', () => { + const internalRepository = ['deepseek-harness', 'deepseek-harness'].join('/') + const source = [ + 'https://github.com/deepseek-ai/deepseek-harness-sdk', + `https://github.com/${internalRepository}/issues/1`, + ].join('\n') + + expect(findInternalRepositoryReferences('subject.md', source)).toEqual([ + { file: 'subject.md', line: 2 }, + ]) + }) +}) diff --git a/scripts/verify-public-repository-links.ts b/scripts/verify-public-repository-links.ts new file mode 100644 index 0000000000..dc8d2b3b35 --- /dev/null +++ b/scripts/verify-public-repository-links.ts @@ -0,0 +1,64 @@ +/** Reject tracked files that expose the internal repository remote. */ + +import { execFileSync } from 'node:child_process' +import { existsSync, lstatSync, readFileSync, readlinkSync } from 'node:fs' +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' + +const root = resolve(import.meta.dirname, '..') +const internalRepository = ['deepseek-harness', 'deepseek-harness'].join('/') + +/** One tracked reference to the internal repository. */ +export interface InternalRepositoryReference { + /** Repository-relative file path. */ + file: string + /** One-based source line. */ + line: number +} + +/** + * Locate internal-repository references in one text file. + * @param file - Repository-relative path used in diagnostics. + * @param source - Text to inspect. + * @returns every matching source line. + */ +export function findInternalRepositoryReferences(file: string, source: string): InternalRepositoryReference[] { + const references: InternalRepositoryReference[] = [] + for (const [index, line] of source.split('\n').entries()) { + if (line.includes(internalRepository)) references.push({ file, line: index + 1 }) + } + return references +} + +function trackedFiles(repoRoot: string): string[] { + return execFileSync('git', ['ls-files', '-z'], { cwd: repoRoot, encoding: 'utf8' }) + .split('\0') + .filter(file => file !== '') +} + +function scanRepository(repoRoot: string): InternalRepositoryReference[] { + const references: InternalRepositoryReference[] = [] + for (const file of trackedFiles(repoRoot)) { + const path = resolve(repoRoot, file) + if (!existsSync(path)) continue + const stat = lstatSync(path) + if (!stat.isFile() && !stat.isSymbolicLink()) continue + const source = stat.isSymbolicLink() ? readlinkSync(path) : readFileSync(path, 'utf8') + if (source.includes('\0')) continue + references.push(...findInternalRepositoryReferences(file, source)) + } + return references +} + +const invokedPath = process.argv[1] +const isMain = invokedPath !== undefined && import.meta.url === pathToFileURL(resolve(invokedPath)).href +if (isMain) { + const references = scanRepository(root) + if (references.length === 0) { + console.log('verify-public-repository-links: tracked files expose no internal repository remote.') + } else { + console.error('verify-public-repository-links: internal repository references found:') + for (const reference of references) console.error(` ${reference.file}:${String(reference.line)}`) + process.exitCode = 1 + } +} diff --git a/website/.vitepress/config.ts b/website/.vitepress/config.ts index da3892eaa2..4dc1a5774b 100644 --- a/website/.vitepress/config.ts +++ b/website/.vitepress/config.ts @@ -94,14 +94,14 @@ const sharedTheme: Pick { const data: unknown = frontmatter const editSource: unknown = typeof data === 'object' && data !== null ? Reflect.get(data, 'editSource') : undefined if (typeof editSource !== 'string') throw new Error('Projected documentation page has no editSource frontmatter.') - return `https://github.com/deepseek-harness/deepseek-harness/edit/master/${editSource}` + return `https://github.com/deepseek-ai/deepseek-harness-sdk/edit/master/${editSource}` }, text: '在 GitHub 上编辑此页', }, @@ -161,7 +161,7 @@ export default withMermaid({ const data: unknown = frontmatter const editSource: unknown = typeof data === 'object' && data !== null ? Reflect.get(data, 'editSource') : undefined if (typeof editSource !== 'string') throw new Error('Projected documentation page has no editSource frontmatter.') - return `https://github.com/deepseek-harness/deepseek-harness/edit/master/${editSource}` + return `https://github.com/deepseek-ai/deepseek-harness-sdk/edit/master/${editSource}` }, text: 'Edit this page on GitHub', }, From 8ccb17690579970ff2430448860f847799c13b78 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 01:17:10 +0800 Subject: [PATCH 047/109] docs: per-model reasoning guide, config catalog, and the feature's Agent Note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The user guide's model-catalog section teaches the three new knobs at task altitude — declare levels per model, pick the reasoning dialect, reshape catalog models with modelOverrides — with the settings.yaml example exercising all of them, plus an UNSUPPORTED_REASONING_EFFORT troubleshooting row. The generated plugin config catalog picks up the new Config fields, and the bilingual Agent Note records the decision, the alternatives considered, and the schemastery materialization constraint that chose false over {} as the disable spelling. --- ...per-model-reasoning-declarations.i18n.yaml | 6 ++ ...-pi-ai-per-model-reasoning-declarations.md | 33 ++++++++ ...-ai-per-model-reasoning-declarations.zh.md | 33 ++++++++ docs/config-catalog.md | 78 ++++++++++++++++++- docs/user/guide/providers.i18n.yaml | 4 +- docs/user/guide/providers.md | 32 +++++++- docs/user/guide/providers.zh.md | 32 +++++++- 7 files changed, 210 insertions(+), 8 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.md create mode 100644 .agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.zh.md diff --git a/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.i18n.yaml new file mode 100644 index 0000000000..3b448f4cf1 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.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/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.md +2026-08-08-pi-ai-per-model-reasoning-declarations.md: 436b5f3f9f30c1bb1dc5816b12ce1596c5d01ec8 +2026-08-08-pi-ai-per-model-reasoning-declarations.zh.md: 47b34dfd270f90fef2802a00e3632777d5636a73 diff --git a/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.md b/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.md new file mode 100644 index 0000000000..436b5f3f9f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.md @@ -0,0 +1,33 @@ +# Agent Note: Per-Model Reasoning Declarations in llm-pi-ai + +Status: implemented + +English | [中文](2026-08-08-pi-ai-per-model-reasoning-declarations.zh.md) + +## Problem + +A hand-declared pi-ai route's models materialized with `reasoning: false`, so `getSupportedThinkingLevels` short-circuited to `["off"]`: the composer offered no effort picker for them, and the route-level `reasoning` default — the only reasoning knob a profile had — made every request to such a model fail with `UNSUPPORTED_REASONING_EFFORT` before network I/O. The same route-level knob was also the wrong altitude for catalog routes: one provider's models disagree about which levels they accept (deepseek ships `[off, high, max]` beside catalog models with `xhigh`), so a single per-route level could not be set without breaking part of the route, which is why the Models page stopped writing it entirely (#1860) and left `settings.yaml` with no way to align efforts per model. + +Two adjacent gaps compounded this. pi-ai decides the reasoning *wire dialect* (`compat.thinkingFormat`, `compat.supportsReasoningEffort`) by recognizing the endpoint URL, and a private gateway's URL says nothing — a DeepSeek-dialect gateway was spoken to in the OpenAI dialect with no configuration that could correct it. And the only way to touch one catalog model was the `models` list, which *replaces* the served catalog: narrowing `gpt-5`'s levels meant restating all thirty-eight openai models or silently dropping thirty-seven. + +## Decision + +`PiAiModelProfile` gains `reasoningEfforts`: **each key is a level selectors offer, its value the spelling dispatch sends on the wire**. The declaration translates to pi-ai's `Model.reasoning` + `thinkingLevelMap` with all seven levels decided explicitly — declared levels carry their wire value, undeclared levels are pinned `null` — so the profile author never needs pi-ai's asymmetric defaulting rule (absent means "supported" for the five base levels but "unsupported" for `xhigh`/`max`). `off` is the one three-state key: left out, thinking cannot be turned off; declared valueless, Off is offered and dispatch sends nothing (the `deepseek` dialect sends `thinking: {type: "disabled"}`); declared with a value, that value goes on the wire. `false` declares a non-reasoning model; an empty declaration is refused rather than guessed at. The spelling for "disable" is `false` rather than `{}` because schemastery materializes an absent dict as `{}` — only a `z.union([z.const(false), dict])` keeps absent, disabled, and declared distinguishable, and a bare `reasoningEfforts:` (YAML null) slips through that union unvalidated, so resolution refuses it explicitly. + +`compat.thinkingFormat` and `compat.supportsReasoningEffort` become configurable at two levels — route (its models' default) and model (winning per field) — resolving model → route → installed catalog entry → pi-ai's URL guess. They exist only on `openai-completions` (pi-ai types them nowhere else): a model-level switch on another protocol fails resolution, a route-level default skips such models, and a route with no completions model at all is refused. The two `chat-template` formats stay withheld for want of `chatTemplateKwargs`. Both enums are pinned to pi-ai's types through `Record` drift gates, so the pi-ai upgrade that adds a format (0.84 added `baseten`) fails compilation until the new member is classified. + +`modelOverrides` reshapes individual catalog models without replacing the served set: key = catalog model id, value = a `models` entry minus `id`, materialized by handing the override to the existing entry path so capacities, efforts, compat, and request-default semantics stay identical. Unlike Pi's own config layer, which ignores unknown ids, every override that lands nowhere is refused — beside a `models` list, on a hand-declared route, naming an unknown model, or smuggling an `id` in the value (the schema passes unknown keys through, and a smuggled id would quietly rename the model). + +## Alternatives considered + +- **Pass `reasoning` + `thinkingLevelMap` through verbatim** (pi-ai's own radius-config shape). Rejected by the user for operator confusion: the map's `null`-marks-unsupported convention plus the asymmetric absent-key rule mean the config's meaning depends on knowledge of pi-ai internals; the chosen shape makes the key set itself the offer. +- **A bare level list** (`reasoningEfforts: [off, high]`). Cannot express wire renames, and the catalog's own maps prove renames are real: 66 of 1230 installed map entries are non-identity (`off→none`, `minimal→low`, `low→LOW`, `high→default`). +- **`{}` as the disable spelling.** Unimplementable: schemastery materializes an absent dict as `{}`, so every model without the field would have been force-disabled. +- **Folding this into the route-level `reasoning` knob.** That knob is a *default selection*, not a capability set; it stays, and a declared model's efforts now bound what it can select. + +## Consequences + +- The composer's effort pane works for hand-declared models with zero UI change — `resolveModelInfo` reports declared levels through the same seam catalog metadata uses (pinned by the `declared-reasoning` web scenario). +- #1860's deferred gap — a route-level effort a model cannot take failing its requests — now has an operator remedy: align the model's `reasoningEfforts` or drop the route default. +- There is deliberately no spelling for returning one map key or compat field to "whatever the catalog said": the declaration is the whole offer, so keeping a catalog value means restating it. The README documents this. +- `verify-package-invariants` is untouched: the feature adds configuration resolution, no new events or mutable runtime relations. diff --git a/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.zh.md b/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.zh.md new file mode 100644 index 0000000000..47b34dfd27 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.zh.md @@ -0,0 +1,33 @@ +# Agent Note: llm-pi-ai 的按模型推理声明 + +Status: implemented + +[English](2026-08-08-pi-ai-per-model-reasoning-declarations.md) | 中文 + +## 问题 + +手工声明的 pi-ai 路由,其模型物化出来就带着 `reasoning: false`,于是 `getSupportedThinkingLevels` 短路成 `["off"]`:输入框不为它们提供档位选择器,而路由级的 `reasoning` 默认值——当时 profile 仅有的推理旋钮——让发往这类模型的每个请求都在网络 I/O 之前以 `UNSUPPORTED_REASONING_EFFORT` 失败。同一个路由级旋钮对 catalog 路由来说也放错了层级:同一提供方下各模型接受的档位并不一致(deepseek 自带 `[off, high, max]`,旁边就是带 `xhigh` 的 catalog 模型),单个路由级档位怎么设都会弄坏路由的一部分——这正是模型页彻底停写它的原因(#1860),而 `settings.yaml` 也因此没有了任何按模型对齐档位的办法。 + +两个相邻的缺口让问题雪上加霜。pi-ai 靠识别端点 URL 来决定推理的*协议方言*(`compat.thinkingFormat`、`compat.supportsReasoningEffort`),而私有网关的 URL 什么也说明不了——说 DeepSeek 方言的网关只会收到 OpenAI 方言的请求,且没有任何配置能更正它。另外,想动单个 catalog 模型,唯一的手段是 `models` 列表,而它会*替换*所服务的 catalog:收窄 `gpt-5` 的档位,意味着要么重述全部三十八个 openai 模型,要么静默丢掉三十七个。 + +## 决策 + +`PiAiModelProfile` 新增 `reasoningEfforts`:**每个键是选择器提供的一个档位,其值是分派在协议中发送的拼写**。该声明会转换为 pi-ai 的 `Model.reasoning` + `thinkingLevelMap`,七个档位全部显式决定——已声明的档位携带自己的协议值,未声明的档位一律固定为 `null`——因此 profile 作者永远不需要了解 pi-ai 那条不对称的默认规则(键缺席对五个基础档位意味着「支持」,对 `xhigh`/`max` 却意味着「不支持」)。`off` 是唯一的三态键:不写,思考就关不掉;声明而不给值,则提供 Off,分派什么也不发送(`deepseek` 方言发送 `thinking: {type: "disabled"}`);声明并给值,该值就在协议中发送。`false` 声明一个不具备推理能力的模型;空声明会被拒绝,而不是去猜。「禁用」的拼写取 `false` 而非 `{}`,因为 schemastery 会把缺席的字典物化成 `{}`——只有 `z.union([z.const(false), dict])` 才能让缺席、禁用与已声明三态保持可区分;而裸写的 `reasoningEfforts:`(YAML null)会不经校验地从该 union 溜过去,因此解析对它显式拒绝。 + +`compat.thinkingFormat` 与 `compat.supportsReasoningEffort` 变为两级可配置——路由级(作为其模型的默认值)与模型级(逐字段胜出)——解析顺序为模型 → 路由 → 已安装 catalog 条目 → pi-ai 按 URL 得出的猜测。两者只存在于 `openai-completions` 上(pi-ai 也只在这一协议上为它们建了类型):在其他协议的模型上设模型级开关会使解析失败,路由级默认值会跳过这类模型,而完全没有 completions 模型的路由则被拒绝。两个 `chat-template` 格式因缺 `chatTemplateKwargs` 而继续保持不开放。两个枚举都经 `Record` 漂移门禁钉在 pi-ai 的类型上,因此新增格式的 pi-ai 升级(0.84 加入了 `baseten`)会编译失败,直到新成员被归类。 + +`modelOverrides` 就地重塑单个 catalog 模型而不替换所服务的集合:键 = catalog 模型 id,值 = 去掉 `id` 的 `models` 条目,物化时把覆盖交给既有的条目路径,因此容量、档位、compat 与请求默认值语义完全一致。与忽略未知 id 的 Pi 自有配置层不同,凡是落不到任何地方的覆盖都会被拒绝——与 `models` 列表并存、写在手工声明的路由上、点名未知模型,或在值里夹带 `id`(schema 会放行未知键,被夹带的 id 会悄悄把模型改名)。 + +## 曾考虑的替代方案 + +- **把 `reasoning` + `thinkingLevelMap` 原样透传**(pi-ai 自家 radius 配置的形状)。用户以运维人员困惑为由否决:map 用 `null` 标记「不支持」的约定,加上不对称的键缺席规则,意味着这份配置的含义取决于对 pi-ai 内部机制的了解;选定的形状则让键集合本身就是对外提供的全部。 +- **裸档位列表**(`reasoningEfforts: [off, high]`)。表达不了协议侧改名,而 catalog 自己的 map 证明改名真实存在:1230 条已安装 map 条目里有 66 条不是恒等映射(`off→none`、`minimal→low`、`low→LOW`、`high→default`)。 +- **用 `{}` 作为禁用拼写。** 无法实现:schemastery 会把缺席的字典物化成 `{}`,于是每个没写该字段的模型都会被强制禁用。 +- **把这件事并进路由级的 `reasoning` 旋钮。** 那个旋钮是*默认选择*,不是能力集合;它保留下来,而已声明模型的档位如今约束着它能选什么。 + +## 后果 + +- 输入框的档位面板对手工声明的模型直接可用,UI 零改动——`resolveModelInfo` 经 catalog 元数据所走的同一 seam 报告已声明档位(由 `declared-reasoning` web 场景钉住)。 +- #1860 暂缓的缺口——模型接不住的路由级档位会让发往它的请求失败——如今有了运维侧补救:对齐该模型的 `reasoningEfforts`,或去掉路由默认值。 +- 刻意不提供任何把单个 map 键或 compat 字段交还给「catalog 原本怎么说」的拼写:这份声明就是对外提供的全部,要保留某个 catalog 值就得重述它。README 记载了这一点。 +- `verify-package-invariants` 原封未动:该功能新增的是配置解析,没有新事件,也没有可变的运行时关系。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 21f38d18e2..ef3721a764 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -767,6 +767,22 @@ export interface PiAiProviderProfile { * unset fields from the installed model of the same id. */ models?: PiAiModelProfile[] + /** + * Installed-catalog customizations by model id: each entry reshapes that + * one model with the same fields a {@link models} entry takes, while the + * rest of the catalog keeps serving untouched. Only meaningful on a catalog + * route with no `models` list — `models` already replaces the catalog, so + * an override beside it, on a route the catalog does not ship, or naming a + * model the catalog does not describe is refused rather than skipped. + */ + modelOverrides?: Record + /** + * Reasoning-dispatch switches for every `openai-completions` model on this + * route; each model's own `compat` overrides per field. What neither sets + * keeps the installed catalog entry's value, then pi-ai's baseURL-derived + * detection. + */ + compat?: PiAiCompatProfile /** * Context capacity for a model this route lists that neither the entry nor * the installed catalog sizes (default 262,144). A guess by construction, so @@ -814,12 +830,70 @@ export interface PiAiModelProfile { * default on its own. */ maxTokens?: number + /** + * Selectable reasoning efforts. Absent inherits the installed catalog + * entry's capability (a hand-declared model has none and does not reason); + * `false` declares a non-reasoning model, which is how a profile strips + * reasoning from a catalog model its gateway cannot serve; a non-empty dict + * declares the offered levels and their wire spellings. + */ + reasoningEfforts?: false | PiAiReasoningEfforts + /** Reasoning-dispatch switches for this model, winning over the route's. */ + compat?: PiAiCompatProfile } + +/** + * Customization of one installed catalog model, keyed by its id in the + * route's `modelOverrides` dict — the same fields a `models` entry may set, + * with the id living in the key. Unlike a `models` list, overrides leave the + * rest of the catalog serving untouched, which is what makes "correct one + * model, keep the other thirty-seven" a three-line edit. + */ +export type PiAiModelOverride = Omit + +/** + * Reasoning-dispatch compatibility switches, set on the route (its models' + * default) or per model (winning over the route). Only the switches pi-ai's + * reasoning dispatch reads are offered; the rest of pi-ai's compat surface + * keeps its baseURL-derived auto-detection. pi-ai types both fields only on + * `OpenAICompletionsCompat` — the other wire protocols carry their reasoning + * shape in the protocol itself — so resolution rejects a model-level switch + * anywhere else, while a route-level default skips past models it cannot fit. + */ +export interface PiAiCompatProfile { + /** Reasoning parameter shape the endpoint expects; absent keeps the catalog entry's, then pi-ai's baseURL-derived guess. */ + thinkingFormat?: PiAiThinkingFormat + /** Whether the endpoint accepts `reasoning_effort`; absent keeps the catalog entry's, then pi-ai's baseURL-derived guess. */ + supportsReasoningEffort?: boolean +} + +/** + * Selectable reasoning efforts for one model: each key is a level the model + * offers (and selectors show), and its value is the wire spelling dispatch + * sends for it. `off` alone may leave its value empty — "supported, send + * nothing" — because for most providers not thinking is the parameter's + * absence; every other declared level must name a wire value. A level absent + * from the dict is not offered. + */ +export type PiAiReasoningEfforts = Partial> + +/** One reasoning-dispatch wire format a profile may name. */ +export type PiAiThinkingFormat = Exclude + +/** The `compat.thinkingFormat` spellings pi-ai accepts on an `openai-completions` model. */ +type PiThinkingFormat = NonNullable + +/** + * pi-ai thinking formats a profile cannot name: both drive the request through + * `chatTemplateKwargs`, which this configuration does not expose, so offering + * them would hand back a format with nothing to say. + */ +type WithheldThinkingFormat = 'chat-template' | 'qwen-chat-template' ``` -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`) +Depends on: `CacheRetention` (`@earendil-works/pi-ai`) · `ModelThinkingLevel` (`@earendil-works/pi-ai`) · `OpenAICompletionsCompat` (`@earendil-works/pi-ai`) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · `ThinkingBudgets` (`@earendil-works/pi-ai`) · `Transport` (`@earendil-works/pi-ai`) -Source: [`packages/llm/llm-pi-ai/src/config.ts:126`](../packages/llm/llm-pi-ai/src/config.ts) +Source: [`packages/llm/llm-pi-ai/src/config.ts:148`](../packages/llm/llm-pi-ai/src/config.ts) ## `@deepseek-ai/dsh-llm-replay` diff --git a/docs/user/guide/providers.i18n.yaml b/docs/user/guide/providers.i18n.yaml index 82c2f2781a..a24c06b8c5 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: 5234f5bb03755c11652eb23f3c5d677fa3cddb40 -providers.zh.md: a54819cab8524a6007c335ad70cecd6516bba25b +providers.md: 6f44daf73037f811164f5b22b14a9c39b71d6b1a +providers.zh.md: 6c75d70d485f55ff230f557247ed6be597a8785e diff --git a/docs/user/guide/providers.md b/docs/user/guide/providers.md index 5234f5bb03..6f44daf730 100644 --- a/docs/user/guide/providers.md +++ b/docs/user/guide/providers.md @@ -59,6 +59,16 @@ llm-pi-ai: - id: claude-sonnet-4-5 contextWindow: 200000 + # Catalog route with one model reshaped in place; the rest of the catalog + # keeps serving (a models list would replace it instead). + deepseek: + apiKeyEnv: DEEPSEEK_API_KEY + modelOverrides: + deepseek-v4-pro: + reasoningEfforts: + off: + high: high + # Hand-declared route: pi-ai ships nothing under this key, so the profile # supplies the whole provider. acme-gateway: @@ -66,11 +76,22 @@ llm-pi-ai: apiKeyEnv: ACME_GATEWAY_API_KEY api: openai-completions baseURL: https://gateway.acme.example/v1 + # Reasoning dialect for an endpoint whose URL pi-ai cannot recognize. + compat: + thinkingFormat: deepseek models: - id: acme-large name: Acme Large contextWindow: 65536 maxTokens: 4096 + - id: acme-think + name: Acme Think + # key = level offered in the picker, value = what goes on the wire; + # only off may leave the value empty (supported, send nothing). + reasoningEfforts: + off: + high: high + max: ultra ``` A settings section merges over the matching `cordis.yml` configuration **per provider**, so you can override one field of one route and leave the rest as the composition set them. @@ -79,9 +100,15 @@ A profile the adapter could not serve is refused **where it is written**: a hand ## The model catalog -A profile's `models` list *replaces* that route's installed catalog rather than extending it; omitting it or leaving it empty serves the catalog unchanged. Each entry defaults its unset fields from the installed model of the same `id`, so narrowing a route to two models, correcting one capacity, or adding a model newer than the installed catalog are each a one-line edit. +A profile's `models` list *replaces* that route's installed catalog rather than extending it; omitting it or leaving it empty serves the catalog unchanged. Each entry defaults its unset fields from the installed model of the same `id`, so narrowing a route to two models, correcting one capacity, or adding a model newer than the installed catalog are each a one-line edit — but once you declare the list, every model the route should keep serving must appear in it, an entry of nothing but `id` being enough. -Only the four fields the harness consumes are configurable: `id`, `name`, `contextWindow`, and `maxTokens`. Pricing and input modalities have no consumer, and reasoning is not per-model configurable at all — it rides the installed catalog entry. +Reshaping a few catalog models while keeping the rest is `modelOverrides`' job: it is keyed by catalog model id, takes the same fields a `models` entry does, and leaves the rest of the catalog serving untouched. An override naming a model the catalog does not describe — or set beside a `models` list, or on a custom provider — is refused rather than silently skipped. + +The configurable model fields are `id`, `name`, `contextWindow`, `maxTokens`, `reasoningEfforts`, and `compat`. Pricing and input modalities have no consumer and ride the installed entry. + +**Declare reasoning levels per model.** `reasoningEfforts` lists the levels a model offers: each key appears in the composer's effort picker, and its value is what dispatch sends on the wire — `high: high` passes the name through, `max: ultra` renames it for a gateway with its own vocabulary. A level you leave out is not offered. `off` is special: declared without a value, Off appears in the picker and selecting it sends nothing; left out entirely, the model cannot stop thinking. `reasoningEfforts: false` declares a non-reasoning model, which is also how you strip reasoning from a catalog model your gateway cannot serve. Without this field a custom model does not reason and a catalog model keeps its catalog levels. + +**Pick the reasoning dialect.** How a level travels — plain `reasoning_effort`, DeepSeek's `thinking: {type}` plus effort, and so on — is normally guessed from the endpoint URL, and a private gateway's URL says nothing, so a DeepSeek-style gateway would be spoken to in the OpenAI dialect. `compat.thinkingFormat` sets the dialect explicitly, and `compat.supportsReasoningEffort: false` holds the parameter back from an endpoint that rejects it; both work on the route (its models' default) or per model, for `openai-completions` routes only. A model neither the entry nor the catalog sizes takes the route's `defaultContextWindow` (262,144) and `defaultMaxTokens` (32,768). Both are guesses by construction, which is why they are route fields: a deployment whose gateway serves smaller models corrects them once. @@ -114,6 +141,7 @@ If the provider a saved default names is later removed, the composer says **Sele - **`MISSING_CREDENTIAL`** — the variable the profile's `apiKeyEnv` names holds no value. Store the key once through the Models page, or export the variable. - **`UNKNOWN_MODEL`** — the requested model is not in the route's configured catalog. Add it to `models`, or use an id the catalog already carries. +- **`UNSUPPORTED_REASONING_EFFORT`** — the request asked the model for a level it does not offer. Pick a level the composer lists for that model, or declare the missing one in the model's `reasoningEfforts`. - **`settings-rejected`** — the written profile cannot be served, and the message names the route and model. For a hand-declared route, check that `api`, `baseURL`, and `models` are all present. - **Fetching available models answers 401** — the endpoint refused the interrogation. Check the key; if the base URL points at an Anthropic-style gateway, note that the interrogation reads only the OpenAI-compatible `GET /models`, so enter the models by hand instead. diff --git a/docs/user/guide/providers.zh.md b/docs/user/guide/providers.zh.md index a54819cab8..6c75d70d48 100644 --- a/docs/user/guide/providers.zh.md +++ b/docs/user/guide/providers.zh.md @@ -59,6 +59,16 @@ llm-pi-ai: - id: claude-sonnet-4-5 contextWindow: 200000 + # Catalog route with one model reshaped in place; the rest of the catalog + # keeps serving (a models list would replace it instead). + deepseek: + apiKeyEnv: DEEPSEEK_API_KEY + modelOverrides: + deepseek-v4-pro: + reasoningEfforts: + off: + high: high + # Hand-declared route: pi-ai ships nothing under this key, so the profile # supplies the whole provider. acme-gateway: @@ -66,11 +76,22 @@ llm-pi-ai: apiKeyEnv: ACME_GATEWAY_API_KEY api: openai-completions baseURL: https://gateway.acme.example/v1 + # Reasoning dialect for an endpoint whose URL pi-ai cannot recognize. + compat: + thinkingFormat: deepseek models: - id: acme-large name: Acme Large contextWindow: 65536 maxTokens: 4096 + - id: acme-think + name: Acme Think + # key = level offered in the picker, value = what goes on the wire; + # only off may leave the value empty (supported, send nothing). + reasoningEfforts: + off: + high: high + max: ultra ``` settings 段落**逐个提供方**地盖在 `cordis.yml` 的同名配置之上,所以你可以只覆盖某个路由的一个字段,其余保持组合里的样子。 @@ -79,9 +100,15 @@ settings 段落**逐个提供方**地盖在 `cordis.yml` 的同名配置之上 ## 模型目录 -`models` 是**替换**该路由的内置目录,不是往里追加;省略或留空则原样使用内置目录。每个条目会从同 `id` 的内置模型继承自己没写的字段,所以「收窄到两个模型」「更正一个容量」「加一个比内置目录更新的模型」都是一行编辑。 +`models` 是**替换**该路由的内置目录,不是往里追加;省略或留空则原样使用内置目录。每个条目会从同 `id` 的内置模型继承自己没写的字段,所以「收窄到两个模型」「更正一个容量」「加一个比内置目录更新的模型」都是一行编辑——但一旦声明了这份列表,该路由要继续服务的每个模型就都必须出现在其中,条目哪怕只写一个 `id` 也足够。 -可配置的只有 harness 会消费的四个字段:`id`、`name`、`contextWindow`、`maxTokens`。定价与输入模态没有消费方,推理能力也不按模型配置——它随内置目录条目走。 +就地重塑目录里的几个模型、保留其余,归 `modelOverrides` 管:它以目录模型 id 为键,接受与 `models` 条目相同的字段,目录的其余部分原样继续服务。覆盖若点名了目录没有描述的模型,或与 `models` 列表并存,或写在自定义提供方上,都会被拒绝,而不是被静默跳过。 + +可配置的模型字段是 `id`、`name`、`contextWindow`、`maxTokens`、`reasoningEfforts` 与 `compat`。定价与输入模态没有消费方,随内置目录条目走。 + +**按模型声明推理档位。** `reasoningEfforts` 列出模型提供的档位:每个键都会出现在输入框的档位选择器里,其值是分派在协议中实际发送的内容——`high: high` 原样透传名称,`max: ultra` 则为使用自有词汇的网关改名。没写的档位不会被提供。`off` 比较特殊:声明而不给值,选择器里会出现 Off,选中它时什么也不发送;完全不写,模型就无法停止思考。`reasoningEfforts: false` 声明一个不具备推理能力的模型,这也是从网关服务不了的目录模型上剥除推理的办法。不写这个字段,自定义模型不推理,目录模型保留目录给出的档位。 + +**选定推理方言。** 档位如何在协议中传输——单独一个 `reasoning_effort`、DeepSeek 的 `thinking: {type}` 加档位,诸如此类——通常靠端点 URL 来猜,而私有网关的 URL 什么也说明不了,于是 DeepSeek 风格的网关只会收到 OpenAI 方言的请求。`compat.thinkingFormat` 用来显式指定方言,`compat.supportsReasoningEffort: false` 则让该参数不再发给拒绝它的端点;两者既可设在路由上(作为其模型的默认值),也可按模型设置,且仅适用于 `openai-completions` 路由。 两处容量都没给出的模型,取路由级兜底 `defaultContextWindow`(262144)与 `defaultMaxTokens`(32768)。这两个数按定义就是猜测,所以它们是路由字段:网关服务的模型更小时改一次即可。 @@ -114,6 +141,7 @@ api-gateway: - **`MISSING_CREDENTIAL`** — profile 里的 `apiKeyEnv` 指向的变量没有值。用模型页存一次密钥,或导出该环境变量。 - **`UNKNOWN_MODEL`** — 请求的模型不在该路由配置的目录里。把它加进 `models`,或改用目录里已有的 id。 +- **`UNSUPPORTED_REASONING_EFFORT`** — 请求向模型要了一个它不提供的档位。从输入框为该模型列出的档位里挑一个,或把缺的那个声明进该模型的 `reasoningEfforts`。 - **`settings-rejected`** — 写入的 profile 服务不了,错误信息会点名具体的路由和模型。手工声明的路由检查 `api`、`baseURL`、`models` 是否齐全。 - **获取可用模型返回 401** — 端点拒绝了这次探测。检查密钥;若地址指向的是 Anthropic 风格网关,注意探测只读 OpenAI 兼容的 `GET /models`,此时手工填写模型即可。 From 0fb474f67206e87f90ef77968a7c3e240da8038a Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 01:27:25 +0800 Subject: [PATCH 048/109] test(web): cover user-only skill invocation end to end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The policy scenario now expects the user-only quadrant in the menu with its marker (riding the description — the hint field is claim-state ghost text, which the menu never renders), and a new skill-user-invoke scenario drives /name args through the composer against the real host: the claim lands skill.invoke, the transcript shows the dedicated card with the collapsed body, and a paced replay answers the injected turn deterministically. --- apps/web/tests/skill-invocation-policy.e2e.ts | 11 +- apps/web/tests/skill-user-invoke.e2e.ts | 145 ++++++++++++++++++ .../skill-invocation-policy/menu.expected.md | 1 + .../skill-user-invoke/ui.expected.md | 31 ++++ packages/client/ui-skill/src/client/index.ts | 5 +- .../ui-skill/tests/browser-plugin.spec.ts | 4 +- 6 files changed, 189 insertions(+), 8 deletions(-) create mode 100644 apps/web/tests/skill-user-invoke.e2e.ts create mode 100644 apps/web/tests/snapshots/skill-user-invoke/ui.expected.md diff --git a/apps/web/tests/skill-invocation-policy.e2e.ts b/apps/web/tests/skill-invocation-policy.e2e.ts index 143bc0d4db..54cd15bf94 100644 --- a/apps/web/tests/skill-invocation-policy.e2e.ts +++ b/apps/web/tests/skill-invocation-policy.e2e.ts @@ -1,5 +1,6 @@ -// Web e2e scenario: the real host filters skill.list to the model-and-user -// intersection before the browser slash source renders candidates. A real +// Web e2e scenario: the real host serves every user-invocable skill to the +// browser slash source — user-only (disable-model-invocation) entries appear +// with their marker while user-disabled quadrants stay hidden. A real // chromium connects a fresh workspace seeded with all four policy quadrants; // no model call is issued, so a stray stream fails loud on the open LLM seam. import { mkdir, writeFile } from 'node:fs/promises' @@ -92,7 +93,7 @@ describe('web e2e: skill invocation policy through the real host', () => { await scaffold?.close() }) - it('renders only the model-and-user intersection in slash candidates', async () => { + it('renders every user-invocable skill and marks the user-only entry', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-skill-invocation-policy')) const input = page.locator('textarea').first() await input.fill('/policy') @@ -102,8 +103,10 @@ describe('web e2e: skill invocation policy through the real host', () => { { timeout: 10_000 }, ).toBe(1) + // The user-only quadrant is invocable here — its only entry point — and + // wears the user-only marker; both user-disabled quadrants stay hidden. + expect(await menu.getByRole('option', { name: /policy-user-only user-only · / }).count()).toBe(1) expect(await menu.getByRole('option', { name: /policy-model-only/ }).count()).toBe(0) - expect(await menu.getByRole('option', { name: /policy-user-only/ }).count()).toBe(0) expect(await menu.getByRole('option', { name: /policy-trusted-only/ }).count()).toBe(0) const snapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd) diff --git a/apps/web/tests/skill-user-invoke.e2e.ts b/apps/web/tests/skill-user-invoke.e2e.ts new file mode 100644 index 0000000000..f722472ded --- /dev/null +++ b/apps/web/tests/skill-user-invoke.e2e.ts @@ -0,0 +1,145 @@ +// Web e2e scenario: a user invokes a disable-model-invocation skill through +// the composer (issue #1470). The entered `/name args` line claims into +// skill.invoke: the real host renders the skill body, injects it as a +// user-role message carrying the skill-invocation source, and starts a turn +// answered by the replay seam. The transcript shows the dedicated invocation +// card (chip + args, body collapsed) and the model's reply. +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import type { ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay' +import { + assertFixtureInventory, + captureStableAria, + compareOrRefreshGolden, + launchWebScaffold, + watchConsole, + webSnapshotMode, + type WebScaffold, +} from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/skill-user-invoke', import.meta.url)) +const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md') +const MODE = webSnapshotMode() + +const SKILL_NAME = 'user-invoke-demo' +const ARGS_TEXT = 'and confirm the fixture wiring' +const REPLY = 'USER_INVOKE_REPLY acknowledged; following the injected skill.' + +async function seedUserOnlySkill(workspaceCwd: string): Promise { + const directory = join(workspaceCwd, 'workspace', '.agents', 'skills', SKILL_NAME) + await mkdir(directory, { recursive: true }) + await writeFile(join(directory, 'SKILL.md'), [ + '---', + `name: ${SKILL_NAME}`, + 'description: Prove user-explicit invocation of a model-hidden skill', + 'disable-model-invocation: true', + '---', + '', + 'Reply with the fixture acknowledgement line.', + '', + ].join('\n')) +} + +const REPLAY: ReplayOverrideDoc = [{ + kind: 'chunks', + chunks: [ + { type: 'block-start', index: 0, blockType: 'text' }, + { type: 'text-delta', index: 0, text: REPLY }, + { type: 'block-end', index: 0, block: { type: 'text', text: REPLY } }, + { type: 'usage', usage: { inputTokens: 256, outputTokens: 16 } }, + { type: 'finish', reason: { kind: 'stop' } }, + ], +}] + +describe.skipIf(MODE === 'record')('web e2e: user-explicit skill invocation through the composer', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let replayDir: string + let tripwire: ReturnType + + beforeAll(async () => { + replayDir = await mkdtemp(join(tmpdir(), 'dsh-skill-user-invoke-replay-')) + const replayOverride = join(replayDir, 'replay.override.json') + await writeFile(replayOverride, JSON.stringify(REPLAY)) + scaffold = await launchWebScaffold({ + replayFixture: join(replayDir, 'override-only.jsonl'), + replayOverride, + // Paced replay keeps the timing-derived chrome (TTFT / tok/s) present + // deterministically; instant playback races it in and out of the golden. + paceMs: 10, + }) + await seedUserOnlySkill(scaffold.workspaceCwd) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + }, 120_000) + + afterAll(async () => { + const failures: unknown[] = [] + await browser?.close().catch((error: unknown) => failures.push(error)) + await scaffold?.close().catch((error: unknown) => failures.push(error)) + if (replayDir !== undefined) { + await rm(replayDir, { recursive: true, force: true }) + .catch((error: unknown) => failures.push(error)) + } + if (failures.length === 1) throw failures[0] + if (failures.length > 1) throw new AggregateError(failures, 'skill-user-invoke e2e cleanup failed') + }) + + it('claims /name args into an injection card and a replayed answer', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-skill-user-invoke')) + const composer = page.locator('textarea:enabled').last() + await composer.waitFor({ timeout: 15_000 }) + + // The menu lists the user-only skill (its only entry point) before enter. + await composer.fill(`/${SKILL_NAME}`) + const menu = page.getByRole('listbox', { name: 'Trigger suggestions' }) + await expect.poll( + () => menu.getByRole('option', { name: new RegExp(SKILL_NAME) }).count(), + { timeout: 10_000 }, + ).toBe(1) + + await composer.fill(`/${SKILL_NAME} ${ARGS_TEXT}`) + await composer.press('Enter') + + // The injection card presents the gesture from source metadata: chip plus + // args, with the rendered collapsed behind a disclosure. + const card = page.locator('[data-skill-invocation]') + await card.waitFor({ timeout: 15_000 }) + const chip = card.locator('[data-ref-chip="skill"]') + expect(await chip.textContent()).toBe(`/${SKILL_NAME}`) + expect(await card.textContent()).toContain(ARGS_TEXT) + + const disclosure = card.locator('details') + expect(await disclosure.getAttribute('open')).toBeNull() + await card.locator('summary').click() + const body = card.locator('pre') + await body.waitFor() + expect(await body.textContent()).toContain(``) + expect(await body.textContent()).toContain('Reply with the fixture acknowledgement line.') + expect(await body.textContent()).toContain(ARGS_TEXT) + await card.locator('summary').click() + + // The injection started a turn; the replay seam answers it. + await page.getByText('USER_INVOKE_REPLY', { exact: false }).first().waitFor({ timeout: 20_000 }) + + const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 60_000) + + it('keeps its snapshot inventory closed', async () => { + await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md']) + }) +}) diff --git a/apps/web/tests/snapshots/skill-invocation-policy/menu.expected.md b/apps/web/tests/snapshots/skill-invocation-policy/menu.expected.md index 11acc39ad0..ca9230b6f1 100644 --- a/apps/web/tests/snapshots/skill-invocation-policy/menu.expected.md +++ b/apps/web/tests/snapshots/skill-invocation-policy/menu.expected.md @@ -1,3 +1,4 @@ - listbox "Trigger suggestions": - text: Skills - option "policy-shared Available to both model and user invocation" [selected] + - option "policy-user-only user-only · Available only to user invocation" diff --git a/apps/web/tests/snapshots/skill-user-invoke/ui.expected.md b/apps/web/tests/snapshots/skill-user-invoke/ui.expected.md new file mode 100644 index 0000000000..b96413f89f --- /dev/null +++ b/apps/web/tests/snapshots/skill-user-invoke/ui.expected.md @@ -0,0 +1,31 @@ +- banner: + - navigation "Session hierarchy": + - button "workspace" [disabled] + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- text: /user-invoke-demo and confirm the fixture wiring +- group: View injected skill content +- text: {{clock}} +- button "Copy": + - img +- button "Context injection @deepseek-ai/dsh-system-prompt": + - img + - img + - text: Context injection @deepseek-ai/dsh-system-prompt +- paragraph: USER_INVOKE_REPLY acknowledged; following the injected skill. +- button "Copy": + - img +- button "Branch into a new conversation": + - img +- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- textbox "Message the agent" +- button "Commands": + - img +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model, current DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash + - img +- button "0% of context used" +- button "Send message" [disabled] +- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 0% Input 256 tok · Output 16 tok diff --git a/packages/client/ui-skill/src/client/index.ts b/packages/client/ui-skill/src/client/index.ts index 7d859bf2fb..3e23cc997b 100644 --- a/packages/client/ui-skill/src/client/index.ts +++ b/packages/client/ui-skill/src/client/index.ts @@ -157,8 +157,9 @@ export function apply(ctx: ClientContext): void { .filter(skill => skill.name.startsWith(query)) .map(skill => ({ name: skill.name, - description: skill.description, - ...skill.modelInvocable ? {} : { hint: userOnlyHint() }, + // The user-only marker rides the description (the menu's only + // secondary text); `hint` is the claim-state ghost text, not a badge. + description: skill.modelInvocable ? skill.description : `${userOnlyHint()} · ${skill.description}`, })) }, warm(session) { diff --git a/packages/client/ui-skill/tests/browser-plugin.spec.ts b/packages/client/ui-skill/tests/browser-plugin.spec.ts index e38adf7686..0e098a0b30 100644 --- a/packages/client/ui-skill/tests/browser-plugin.spec.ts +++ b/packages/client/ui-skill/tests/browser-plugin.spec.ts @@ -388,7 +388,7 @@ describe('adjudication', () => { }) describe('user-only marking', () => { - it('carries the user-only hint on candidates the model cannot invoke', async () => { + it('prefixes the description of candidates the model cannot invoke', async () => { const rows: SkillRow[] = [ { name: 'shared-skill', description: 'both surfaces', modelInvocable: true }, { name: 'user-only-skill', description: 'user surface only', modelInvocable: false }, @@ -397,7 +397,7 @@ describe('user-only marking', () => { const candidates = await source.candidates(proj('s1'), req('')) expect(candidates).toEqual([ { name: 'shared-skill', description: 'both surfaces' }, - { name: 'user-only-skill', description: 'user surface only', hint: '仅用户' }, + { name: 'user-only-skill', description: '仅用户 · user surface only' }, ]) }) }) 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 049/109] 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 74ba0b532edd355a974541d1e1663a5f7c77f939 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 01:33:56 +0800 Subject: [PATCH 050/109] chore: sync the lockfile for the dsh-skill llm dependency --- pnpm-lock.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bd4ff1ea14..045d76aeab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5155,6 +5155,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm cordis: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis From 95c01e6e61195c3c993fb306ffea8a5627e1224b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 01:40:14 +0800 Subject: [PATCH 051/109] cleanup: reject private issue shorthand --- .../2026-08-06-api-key-format-validation.i18n.yaml | 4 ++-- .../bug-fix/2026-08-06-api-key-format-validation.md | 6 ++---- .../2026-08-06-api-key-format-validation.zh.md | 6 ++---- scripts/verify-public-repository-links.spec.ts | 7 +++++-- scripts/verify-public-repository-links.ts | 12 ++++++++---- 5 files changed, 19 insertions(+), 16 deletions(-) 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 e1c3ac3ef8..f4f105e124 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: e9ca76ede06080f2b868f6436998d163e642adbc -2026-08-06-api-key-format-validation.zh.md: 5666a884d4c9478291072375681d8d3526b2632a +2026-08-06-api-key-format-validation.md: 2174cb466c6af72f15005ce1ba3dec8100de6f2f +2026-08-06-api-key-format-validation.zh.md: f9b7d6518fedc42e5264c46beaa1a78619139c58 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 e9ca76ede0..2174cb466c 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 @@ -14,13 +14,11 @@ Pasting a key containing an emoji, CJK text, or a full-width punctuation mark in 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. - ## Decision One rule defines a legal key: **after trimming, non-empty, and every character within `[\x21-\x7E]`** — printable ASCII, space excluded. -This single predicate covers every input the sources list: empty, leading and trailing whitespace, interior whitespace, C0 control characters, emoji, CJK text, and full-width punctuation. It is also exactly the constraint that produced the ByteString failure, so the two issues close on one definition rather than on two coincidentally related fixes. +This single predicate covers every reported input: empty, leading and trailing whitespace, interior whitespace, C0 control characters, emoji, CJK text, and full-width punctuation. It is also exactly the constraint that produced the ByteString failure, so the failures share one definition rather than two coincidentally related fixes. A second, narrower rule catches a pasted environment line: input matching `^[A-Z][A-Z0-9_]*=[^=]` or wrapped in matching quotes is refused. Restricting the prefix to upper-case keeps real keys clear of it — `sk-` forms break the identifier match at the hyphen — and requiring a non-`=` character after the separator keeps base64 padding clear of it too. It reports the same format failure as an illegal character rather than its own message: the reader's next move is identical either way, so a separate line would name a cause without changing what to do. @@ -76,7 +74,7 @@ The client cannot import any of this: client packages reference only client pack **Running the shape heuristic in the resolvers too.** Symmetric, and it would stop a pasted environment line written directly into `.env`. Rejected for the lockout described above: a false positive in a resolver leaves the user no working path, while a false positive in the browser leaves the environment open. -**Probing the provider at save time to prove the key works.** It would close the complaint the sources actually open with — a save that reports success and fails at the first turn. Rejected as out of scope and, on the code as it stood, unbuildable: `discoverModels` short-circuits to the installed catalog before any network call for exactly the providers pi-ai ships catalogs for, so it verified nothing about the key, and the DeepSeek card has no probe at all. A verifier's value is distinguishing "key rejected" from "cannot reach", which is the distinction this change makes reliable; building it first would have produced a verifier unable to tell its own outcomes apart. Comparable products also do not verify on save, so a blocking network call there would be an unexpected behavior rather than a missing one. +**Probing the provider at save time to prove the key works.** It would close the original complaint — a save that reports success and fails at the first turn. Rejected as out of scope and, on the code as it stood, unbuildable: `discoverModels` short-circuits to the installed catalog before any network call for exactly the providers pi-ai ships catalogs for, so it verified nothing about the key, and the DeepSeek card has no probe at all. A verifier's value is distinguishing "key rejected" from "cannot reach", which is the distinction this change makes reliable; building it first would have produced a verifier unable to tell its own outcomes apart. Comparable products also do not verify on save, so a blocking network call there would be an unexpected behavior rather than a missing one. ## Consequences 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 5666a884d4..f9b7d6518f 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 @@ -14,13 +14,11 @@ Status: implemented 空白字符能通过每一道检查。`ProviderEditor` 判的是 `keyDraft.length`,于是三个空格构成的 Key 会被存下,随后以 `Bearer` 加若干空格去认证。两个适配器都不检查来自凭据或环境的 Key——而那正是 Models 页写入的路径,也就是用户真正走的路径。 -来源:deepseek-harness#1594 与 #1595;dsh-external#247、#249、#266、#210。 - ## Decision 一条规则定义什么是合法 Key:**trim 之后非空,且每个字符都落在 `[\x21-\x7E]`**——可打印 ASCII,不含空格。 -这一个断言覆盖了来源列出的全部输入:空值、首尾空白、中间空白、C0 控制字符、emoji、中文、全角标点。它同时正是造成 ByteString 失败的那条约束,所以两个 issue 收敛于同一个定义,而不是两个恰好相关的修复。 +这一个断言覆盖了所有已报告的输入:空值、首尾空白、中间空白、C0 控制字符、emoji、中文、全角标点。它同时正是造成 ByteString 失败的那条约束,所以这些故障收敛于同一个定义,而不是两个恰好相关的修复。 第二条更窄的规则用于识别整行粘贴的环境变量:匹配 `^[A-Z][A-Z0-9_]*=[^=]` 或首尾成对引号的输入会被拒绝。把前缀限定为全大写可以让真实 Key 与之绝缘——`sk-` 这类形态会在连字符处中断标识符匹配——而要求分隔符之后必须是非 `=` 字符,则让 base64 的 padding 也与之绝缘。它报出的是与非法字符相同的那条格式失败,而不是自己的一句:读到它的人下一步动作完全一样,因此单列一句只会点出一个原因,却不改变该怎么做。 @@ -76,7 +74,7 @@ Status: implemented **让形状启发式也在 resolver 中运行。** 更对称,且能拦住直接写进 `.env` 的整行环境变量。因上文所述的锁死风险而否决:resolver 中的一次误判会让用户无路可走,浏览器中的一次误判则仍留有环境变量这条路。 -**在保存时探测 provider 以证明 Key 可用。** 它能关掉来源真正开篇抱怨的那件事——保存报成功、第一轮才失败。因超出范围而否决,且在当时的代码上无法建成:对 pi-ai 恰好自带 catalog 的那些 provider,`discoverModels` 会在任何网络调用之前短路到内置 catalog,因而对 Key 什么都验证不了;而 DeepSeek 卡片根本没有探测。验证器的价值在于分清「Key 被拒」与「无法连通」,而这正是本次改动让其变得可靠的区分;先建验证器只会得到一个分不清自身结果的验证器。同类产品也不在保存时验证,因此保存时的阻断式网络调用会是一个意外行为,而非一处缺失。 +**在保存时探测 provider 以证明 Key 可用。** 它能关掉最初报告的那件事——保存报成功、第一轮才失败。因超出范围而否决,且在当时的代码上无法建成:对 pi-ai 恰好自带 catalog 的那些 provider,`discoverModels` 会在任何网络调用之前短路到内置 catalog,因而对 Key 什么都验证不了;而 DeepSeek 卡片根本没有探测。验证器的价值在于分清「Key 被拒」与「无法连通」,而这正是本次改动让其变得可靠的区分;先建验证器只会得到一个分不清自身结果的验证器。同类产品也不在保存时验证,因此保存时的阻断式网络调用会是一个意外行为,而非一处缺失。 ## Consequences diff --git a/scripts/verify-public-repository-links.spec.ts b/scripts/verify-public-repository-links.spec.ts index b05dcb65d1..615bfa68e2 100644 --- a/scripts/verify-public-repository-links.spec.ts +++ b/scripts/verify-public-repository-links.spec.ts @@ -2,15 +2,18 @@ import { describe, expect, it } from 'vitest' import { findInternalRepositoryReferences } from './verify-public-repository-links.ts' describe('public repository link policy', () => { - it('rejects the internal remote and accepts the public home', () => { - const internalRepository = ['deepseek-harness', 'deepseek-harness'].join('/') + it('rejects internal repository references and accepts the public home', () => { + const internalOwner = ['deepseek', 'harness'].join('-') + const internalRepository = [internalOwner, internalOwner].join('/') const source = [ 'https://github.com/deepseek-ai/deepseek-harness-sdk', `https://github.com/${internalRepository}/issues/1`, + `${internalOwner}#2`, ].join('\n') expect(findInternalRepositoryReferences('subject.md', source)).toEqual([ { file: 'subject.md', line: 2 }, + { file: 'subject.md', line: 3 }, ]) }) }) diff --git a/scripts/verify-public-repository-links.ts b/scripts/verify-public-repository-links.ts index dc8d2b3b35..a57628e00c 100644 --- a/scripts/verify-public-repository-links.ts +++ b/scripts/verify-public-repository-links.ts @@ -1,4 +1,4 @@ -/** Reject tracked files that expose the internal repository remote. */ +/** Reject tracked files that expose the internal repository identity. */ import { execFileSync } from 'node:child_process' import { existsSync, lstatSync, readFileSync, readlinkSync } from 'node:fs' @@ -6,7 +6,9 @@ import { resolve } from 'node:path' import { pathToFileURL } from 'node:url' const root = resolve(import.meta.dirname, '..') -const internalRepository = ['deepseek-harness', 'deepseek-harness'].join('/') +const internalOwner = ['deepseek', 'harness'].join('-') +const internalRepository = [internalOwner, internalOwner].join('/') +const internalIssueShorthand = `${internalOwner}#` /** One tracked reference to the internal repository. */ export interface InternalRepositoryReference { @@ -25,7 +27,9 @@ export interface InternalRepositoryReference { export function findInternalRepositoryReferences(file: string, source: string): InternalRepositoryReference[] { const references: InternalRepositoryReference[] = [] for (const [index, line] of source.split('\n').entries()) { - if (line.includes(internalRepository)) references.push({ file, line: index + 1 }) + if (line.includes(internalRepository) || line.includes(internalIssueShorthand)) { + references.push({ file, line: index + 1 }) + } } return references } @@ -55,7 +59,7 @@ const isMain = invokedPath !== undefined && import.meta.url === pathToFileURL(re if (isMain) { const references = scanRepository(root) if (references.length === 0) { - console.log('verify-public-repository-links: tracked files expose no internal repository remote.') + console.log('verify-public-repository-links: tracked files expose no internal repository identity.') } else { console.error('verify-public-repository-links: internal repository references found:') for (const reference of references) console.error(` ${reference.file}:${String(reference.line)}`) From eba4df1e86c4b2e94792178e5f35b3330814b3c8 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 01:50:37 +0800 Subject: [PATCH 052/109] cleanup: skip redundant Issue lifecycle ready runs --- ...-driven-issue-lifecycle-triggers.i18n.yaml | 6 ++++ ...-review-driven-issue-lifecycle-triggers.md | 31 +++++++++++++++++++ ...view-driven-issue-lifecycle-triggers.zh.md | 31 +++++++++++++++++++ .github/workflows/issue-lifecycle.yml | 1 - scripts/ci-workflow.spec.ts | 28 +++++++++++++++++ 5 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 .agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.md create mode 100644 .agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.zh.md diff --git a/.agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.i18n.yaml new file mode 100644 index 0000000000..a82d54640c --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.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-review-driven-issue-lifecycle-triggers.md +2026-08-08-review-driven-issue-lifecycle-triggers.md: 8a2d48ee23da4c20bb832ae0109e2ea9912dac83 +2026-08-08-review-driven-issue-lifecycle-triggers.zh.md: 004739ff471815b0fe12e111eba0ec7aaaef9507 diff --git a/.agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.md b/.agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.md new file mode 100644 index 0000000000..8a2d48ee23 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.md @@ -0,0 +1,31 @@ +# Agent Note: Review-driven Issue lifecycle triggers + +Status: implemented + +English | [中文](2026-08-08-review-driven-issue-lifecycle-triggers.zh.md) + +## Problem + +The Issue lifecycle workflow reads the current pull request after each subscribed repository event and projects resolving Issues forward to `In progress` or `In review`. A resolving draft already reaches `In progress` from its `opened` event. Changing that draft to ready creates no new lifecycle outcome until a reviewer is requested or submits a review, yet subscribing to `ready_for_review` launches another hosted job and creates another GitHub App token. + +Draft-to-ready automation commonly submits a review moments later. In that sequence the ready job cannot advance the Issue, while the review job is still required to observe the `In review` phase. + +## Decision + +[Issue lifecycle](../../../../.github/workflows/issue-lifecycle.yml) does not subscribe to `pull_request.ready_for_review`. It retains `pull_request.review_requested` and `pull_request_review.submitted`, so either a requested reviewer or a submitted review can advance a resolving Issue to `In review`. The handler continues to fetch the live pull request instead of deriving phase from the triggering payload. + +[Issue policy](../../../../.github/workflows/issue-policy.yml) still subscribes to `ready_for_review`. That workflow owns the required check when a human pull request enters review; removing a lifecycle trigger does not weaken policy enforcement. + +The workflow test parses both files and pins this split. The lifecycle policy tests separately pin that draft and open resolving pull requests reach `In progress`, while a review request or submitted review reaches `In review`. + +## Alternatives considered + +- **Keep both events and cancel an in-progress run** - rejected because concurrency can discard a pending run but cannot combine two webhook payloads into one execution. Cancelling the earlier mutation also makes correctness depend on arrival order, while a completed ready job still consumes the full runner setup. +- **Remove the submitted-review event** - rejected because a review may arrive without an explicit review request. In that path `pull_request_review.submitted` is the only repository event that exposes the transition to `In review`. +- **Delay every pull request event behind a debounce dispatcher** - rejected because another queue or scheduled workflow adds latency and control-plane state to eliminate a trigger that carries no lifecycle information. + +## Consequences + +A draft becoming ready no longer launches Issue lifecycle work. The resolving Issue remains `In progress` from an earlier pull request event until a review is requested or submitted, at which point one review-driven run can advance it to `In review`. The required Issue policy check still runs at the ready boundary. + +If a future lifecycle phase depends on ready status itself, that change must restore the trigger and update the workflow test and this decision. Until then, omitting `ready_for_review` saves one hosted run from the common ready-then-review sequence without dropping a status transition. diff --git a/.agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.zh.md b/.agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.zh.md new file mode 100644 index 0000000000..004739ff47 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.zh.md @@ -0,0 +1,31 @@ +# Agent Note: 由评审驱动的 Issue 生命周期触发器 + +Status: implemented + +[English](2026-08-08-review-driven-issue-lifecycle-triggers.md) | 中文 + +## 问题 + +Issue 生命周期工作流会在每个已订阅的仓库事件发生后读取当前 PR(Pull Request),并将解决型 Issue 的状态向前推进到 `In progress` 或 `In review`。解决型草稿 PR 已通过其 `opened` 事件进入 `In progress`。在请求评审人或评审人提交评审之前,把该草稿转为可评审状态不会产生新的生命周期结果;但订阅 `ready_for_review` 仍会启动另一个托管作业,并创建另一个 GitHub App token。 + +草稿转为可评审状态的自动化通常会在片刻后提交评审。在这一事件序列中,转为可评审状态的作业无法推进 Issue,而要观察到 `In review` 阶段,仍必须运行评审作业。 + +## 决策 + +[Issue 生命周期](../../../../.github/workflows/issue-lifecycle.yml)不订阅 `pull_request.ready_for_review`。它保留 `pull_request.review_requested` 和 `pull_request_review.submitted`,因此无论是请求评审人还是提交评审,都可以将解决型 Issue 推进至 `In review`。处理程序仍会获取实时 PR,而不是根据触发事件的载荷推导阶段。 + +[Issue 政策](../../../../.github/workflows/issue-policy.yml)仍订阅 `ready_for_review`。该工作流负责在由人类发起的 PR 进入评审时执行必需检查;移除生命周期触发器不会削弱政策执行。 + +工作流测试会解析这两个文件,并固定这种划分。生命周期政策测试另行固定以下行为:草稿及开放状态的解决型 PR 会进入 `In progress`,评审请求或已提交评审则会使其进入 `In review`。 + +## 考虑过的替代方案 + +- **保留两个事件并取消正在进行的工作流运行**:不予采纳,因为并发控制可以丢弃待处理的工作流运行,却无法把两个 webhook 载荷合并为一次执行。取消较早的状态变更操作也会使正确性依赖事件到达顺序;而已经完成的转为可评审状态作业仍会产生完整的运行器初始化开销。 +- **移除已提交评审事件**:不予采纳,因为评审可能在没有明确评审请求的情况下直接提交。在这条路径中,`pull_request_review.submitted` 是唯一能让系统观察到进入 `In review` 这一状态转换的仓库事件。 +- **让每个 PR 事件都先经过防抖分派器再处理**:不予采纳,因为新增一条队列或一个定时工作流会引入延迟和控制平面状态,只为消除一个不携带生命周期信息的触发器。 + +## 后果 + +草稿转为可评审状态后,不再启动 Issue 生命周期工作。解决型 Issue 会保持在更早的 PR 事件所设定的 `In progress`,直到请求或提交评审;届时,一次由评审驱动的工作流运行即可将其推进至 `In review`。必需的 Issue 政策检查仍会在转为可评审状态的边界运行。 + +如果未来某个生命周期阶段依赖可评审状态本身,相关变更必须恢复该触发器,并更新工作流测试和本决策。在此之前,省略 `ready_for_review` 可使常见的先转为可评审状态、再提交评审这一序列少启动一次托管工作流运行,而不会遗漏状态转换。 diff --git a/.github/workflows/issue-lifecycle.yml b/.github/workflows/issue-lifecycle.yml index 4dc6869e27..7a25b5223d 100644 --- a/.github/workflows/issue-lifecycle.yml +++ b/.github/workflows/issue-lifecycle.yml @@ -21,7 +21,6 @@ on: - reopened - labeled - unlabeled - - ready_for_review - review_requested pull_request_review: types: diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index 7febf0049a..baeac7a8c0 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -28,6 +28,34 @@ describe('CI workflow', () => { }) }) +describe('Issue lifecycle workflow', () => { + it('uses review signals instead of rerunning when a draft becomes ready', () => { + const lifecycle = loadWorkflow('.github/workflows/issue-lifecycle.yml') + const lifecyclePullRequest = workflowEvent(lifecycle, 'pull_request') + const lifecycleReview = workflowEvent(lifecycle, 'pull_request_review') + const policy = loadWorkflow('.github/workflows/issue-policy.yml') + const policyPullRequest = workflowEvent(policy, 'pull_request') + + expect(lifecyclePullRequest.types).not.toContain('ready_for_review') + expect(lifecyclePullRequest.types).toContain('review_requested') + expect(lifecycleReview.types).toContain('submitted') + expect(policyPullRequest.types).toContain('ready_for_review') + }) +}) + +function loadWorkflow(path: string): Record { + const workflow: unknown = yaml.load(readFileSync(resolve(root, path), 'utf8')) + if (!isRecord(workflow)) throw new TypeError(`${path} must define a workflow`) + return workflow +} + +function workflowEvent(workflow: Record, event: string): Record { + if (!isRecord(workflow.on) || !isRecord(workflow.on[event])) { + throw new TypeError(`workflow must define the ${event} event`) + } + return workflow.on[event] +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } From db146f0eba2c4b26987beff5a7a2e243a2c2ebe8 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 01:52:42 +0800 Subject: [PATCH 053/109] refactor(host): share the turn-start route refusal between prompt and skill.invoke turnAgentFor owns the addressed-agent resolution and the model-unavailable refusal both turn-starting methods repeat; the duplication gate flagged the copied block. --- packages/host/apiproxy/src/api-proxy.ts | 66 +++++++++++++------------ 1 file changed, 35 insertions(+), 31 deletions(-) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 6384a4d408..3970a801a3 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -1248,6 +1248,35 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro return llm === undefined || llm.listProviders().some(entry => entry.id === provider) } + /** + * Resolve the addressed agent for a turn-starting method and refuse when no + * adapter serves its current route: a route nothing serves cannot start a + * turn, and letting it try spends the whole pre-step path to fail inside + * the adapter with a message about registration. Refusing here names the + * model the session is pointed at while the draft is still in the composer. + * This is the enforcement boundary shared by `session.prompt` and + * `skill.invoke`: a client that disables its input is an affordance, and + * both methods stay callable regardless. + */ + async function turnAgentFor( + request: RpcRequest, sessionId: SessionId, + ): Promise<{ agent: Agent } | { refused: RpcResponse }> { + const found = await agentFor(sessionId) + if ('error' in found) return { refused: err(request, found.error) } + const agent = found.agent + const target = targetFor(agent).current + if (!routeServed(target.provider)) { + return { + refused: err(request, { + code: 'model-unavailable', + message: `no adapter serves provider "${target.provider}"; select a model for this session`, + details: { provider: target.provider, model: target.model }, + }), + } + } + return { agent } + } + /** Missing-service report shared by the settings domain (skills-domain stance). */ function settingsAbsent(): RpcError { return { code: 'internal', message: 'settings service is absent: this deployment does not mount a settings provider (e.g. @deepseek-ai/dsh-settings-local) in its composition', details: {} } @@ -1784,23 +1813,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro async prompt(request) { const { sessionId, mode, content } = request.payload - const found = await agentFor(sessionId) - if ('error' in found) return err(request, found.error) - const agent = found.agent - // A route no adapter serves cannot start a turn, and letting it try - // spends the whole pre-step path to fail inside the adapter with a - // message about registration. Refusing here names the model the - // session is pointed at while the draft is still in the composer. - // This is the enforcement boundary: a client that disables its input - // is an affordance, and this method stays callable regardless. - const target = targetFor(agent).current - if (!routeServed(target.provider)) { - return err(request, { - code: 'model-unavailable', - message: `no adapter serves provider "${target.provider}"; select a model for this session`, - details: { provider: target.provider, model: target.model }, - }) - } + const resolved = await turnAgentFor<{ accepted: true }>(request, sessionId) + if ('refused' in resolved) return resolved.refused + const agent = resolved.agent // The rpcId rides MessageSource into user/message (merge declaration in api/sessions.ts; provisional correlation). const source: MessageSource = { kind: 'user', rpcId: request.rpcId } try { @@ -2377,20 +2392,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro async invoke(request) { const { sessionId, name, text } = request.payload - const found = await agentFor(sessionId) - if ('error' in found) return err(request, found.error) - const agent = found.agent - // Same turn-start refusal boundary as sessions.prompt: injection - // starts a turn, so a route no adapter serves is refused while the - // composer still shows the draft. - const target = targetFor(agent).current - if (!routeServed(target.provider)) { - return err(request, { - code: 'model-unavailable', - message: `no adapter serves provider "${target.provider}"; select a model for this session`, - details: { provider: target.provider, model: target.model }, - }) - } + const resolved = await turnAgentFor<{ accepted: true }>(request, sessionId) + if ('refused' in resolved) return resolved.refused + const agent = resolved.agent const skillRegistry = ctx.get('skills') if (skillRegistry === undefined) { return err(request, { code: 'internal', message: 'skill registry is absent: this deployment does not mount @deepseek-ai/dsh-skill in its composition (cordis.yml or explicit assembly)', details: {} }) From 3584d8e08804aae652dcaa43ed63052b6cddc50c Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 01:52:43 +0800 Subject: [PATCH 054/109] docs(skill): document the user-explicit invocation path Bilingual README updates for the four touched packages (ui-skill's claim flow and deterministic-injection model experience, the apiproxy skills domain, the shared renderSkillContent seam export, the catalog stitch sentence), the implemented Agent Note triplet recording the decision and its peer-product evidence, and the regenerated catalogs/graphs. --- ...8-user-explicit-skill-invocation.i18n.yaml | 6 ++++ ...26-08-08-user-explicit-skill-invocation.md | 36 +++++++++++++++++++ ...08-08-user-explicit-skill-invocation.zh.md | 36 +++++++++++++++++++ docs/config-catalog.md | 4 +-- docs/cordis-catalog/events.md | 2 +- docs/cordis-catalog/services.md | 2 +- docs/event-producer-consumer.md | 2 +- packages/client/ui-skill/README.i18n.yaml | 4 +-- packages/client/ui-skill/README.md | 15 ++++---- packages/client/ui-skill/README.zh.md | 15 ++++---- packages/host/apiproxy/README.i18n.yaml | 4 +-- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/skill/skill/README.i18n.yaml | 4 +-- packages/skill/skill/README.md | 4 +++ packages/skill/skill/README.zh.md | 4 +++ packages/skill/tool-skill/README.i18n.yaml | 4 +-- packages/skill/tool-skill/README.md | 3 +- packages/skill/tool-skill/README.zh.md | 3 +- 19 files changed, 121 insertions(+), 31 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md create mode 100644 .agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.zh.md diff --git a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.i18n.yaml new file mode 100644 index 0000000000..ed9de78dbb --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.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/feature/2026-08-08-user-explicit-skill-invocation.md +2026-08-08-user-explicit-skill-invocation.md: 9249ee5c9c712e9c6aa827e97178f352728ed927 +2026-08-08-user-explicit-skill-invocation.zh.md: f15975c3b13fbf76e036fcece30253e78e7b417d diff --git a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md new file mode 100644 index 0000000000..9249ee5c9c --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md @@ -0,0 +1,36 @@ +# Agent Note: User-explicit skill invocation over skill.invoke + +Status: implemented + +English | [中文](2026-08-08-user-explicit-skill-invocation.zh.md) + +## Problem + +A `disable-model-invocation: true` skill is user-only by design: it never enters the model-facing catalog and the `skill` tool refuses to load it. Its only legitimate entry point is an explicit user gesture — yet the web client had none. `skill.list` filtered to the model-and-user intersection (hiding user-only skills from the menu), an entered `/name` line rode into the default prompt sink as plain text, and the model it reached was forbidden to load the skill — so it degraded to `read`-ing the SKILL.md file or ignoring the gesture (issue #1470). Even for ordinary skills, the decision-21 plain-text reference made user invocation a collaboration cue the model could ignore, not a guarantee. + +## Decision + +User-explicit invocation is a deterministic host-side injection, uniform for every user-invocable skill: + +- `skill.invoke { sessionId, name, text? }` (host apiproxy) enforces user-invocation policy at the operation boundary (`skill-not-found` / `skill-not-invocable`), renders the skill with the shared `renderSkillContent`, appends the optional trailing text after a blank line, and injects the whole as one user-role message carrying the new `skill-invocation` `MessageSource` kind (`{ name, args? }`) before starting a turn through the same route-served gate as `session.prompt`. +- `renderSkillContent` moved from `dsh-tool-skill` to the `dsh-skill` seam: the `skill` tool result and the injection share one verbatim `` shape, and the catalog text gained the seam rule — an inline-injected skill must be followed, not re-loaded through the tool. +- `skill.list` serves every user-invocable skill and carries `modelInvocable`, so the browser menu lists user-only skills with a marker (description prefix — the `hint` field is claim-state ghost text the menu never renders). +- ui-skill claims a menu pick or an entered `/name [args]` into the invoke transaction (`matchEnter` strong-waits the catalog; unknown names stay plain prompts). The unreached legacy `name` reference codec is removed. +- The transcript materializes the injection as a dedicated `skill-invocation` node from source metadata (never re-parsed from the body) and renders a right-aligned bubble: `/name` chip, trailing text, and the injected block collapsed behind a disclosure. + +Peer-product survey (Pi, OpenCode, Claude Code, Kimi Code, Codex, DeepSeek-Reasonix — local checkouts) was unanimous: user-explicit triggering is programmatic injection as a user-role message with zero model participation on every product, prompt-guided tool loading exists only on the model-autonomous track, and the disable-model-invocation equivalents gate only the model-side surfaces. Kimi's origin-metadata rendering and the Claude Code/Kimi no-reload prompt rule translate directly onto `MessageSource` and the catalog sentence. + +## Alternatives considered + +- **`agent.inject()` context injection** — no peer precedent; the gesture is a user turn, not an environment notice, and context-row presentation, compaction, and attribution all mismatch. Rejected. +- **A host `/skill ` command** (command registry, plan-mode precedent) — two-token UX, no name completion, and user-only skills stay undiscoverable in the menu; the per-cwd skill catalog also fits the static command registry poorly. Rejected. +- **Client-side expansion** (fetch body, splice into the prompt) — authorization becomes bypassable client courtesy, the log loses the invocation semantics, and Codex deleted its equivalent mechanism (custom prompts) in favor of core injection. Rejected. +- **Host prompt-pipeline scanning for `/name`** (Codex `$name` core mentions) — duplicates the adjudication layer and risks swallowing literal slashes in prose; the claim path already covers the need. Rejected. +- **Per-injection preamble line** (Kimi's `User activated the skill …`) — dropped in favor of a one-time catalog sentence: same context, paid once, and the injected block stays byte-identical with the tool result. + +## Consequences + +- Decision 21's plain-text reference path is superseded at submission: the draft still carries plain text and lexicon-derived chip visuals, but submit claims into a deterministic injection instead of shipping the literal and hoping. The model-autonomous track (catalog + `skill` tool) is unchanged. +- Every user-invocable skill invocation now costs its full rendered body unconditionally — the price of determinism the peer survey showed everyone pays. +- The `skill-invocation` source rides `user/message`, so Model-visible ⟺ logged holds with no new event type, and replay/UI read metadata rather than text markers. +- TUI and ACP can adopt `skill.invoke` later for the same semantics; until then the TUI's client-side expansion remains its own path. diff --git a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.zh.md b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.zh.md new file mode 100644 index 0000000000..f15975c3b1 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.zh.md @@ -0,0 +1,36 @@ +# Agent Note: 经 skill.invoke 的用户显式 skill 调用 + +Status: implemented + +[English](2026-08-08-user-explicit-skill-invocation.md) | 中文 + +## 问题 + +`disable-model-invocation: true` 的 skill(技能)在设计上就是仅限用户的:它绝不进入面向模型的目录,`skill` 工具也拒绝加载它。它唯一正当的入口是一次显式的用户手势——而 web 客户端此前没有这个入口。`skill.list` 过滤到模型与用户的交集(把仅限用户的 skill 挡在菜单之外),回车提交的 `/name` 一行以纯文本落入默认提示词 sink,而这行文本到达的模型又被禁止加载该 skill——于是退化为模型去 `read` 那份 SKILL.md 文件,或者干脆无视这次手势(issue #1470)。即使对普通 skill,决策 21 的纯文本引用也让用户调用只是模型可以忽略的协作线索,而不是保证。 + +## 决策 + +用户显式调用是一次确定性的宿主侧注入,对每一个用户可调用的 skill 一致: + +- `skill.invoke { sessionId, name, text? }`(宿主 apiproxy)在操作边界强制执行用户调用策略(`skill-not-found`/`skill-not-invocable`),用共享的 `renderSkillContent` 渲染该 skill,在一个空行之后追加可选的尾随文本,并把整体作为一条携带新增 `skill-invocation` `MessageSource` kind(`{ name, args? }`)的 user 角色消息注入,随后经由与 `session.prompt` 相同的「路由是否有适配器在服务」闸门开启一个轮次。 +- `renderSkillContent` 从 `dsh-tool-skill` 移入 `dsh-skill` seam:`skill` 工具结果与注入共享同一份逐字一致的 `` 形态,目录文本则新增了这条 seam 规则——已内联注入的 skill 必须被遵循,而不是再经工具重新加载。 +- `skill.list` 提供每一个用户可调用的 skill 并携带 `modelInvocable`,因此浏览器菜单会带标记地列出仅限用户的 skill(描述前缀——`hint` 字段是认领态的 ghost text,菜单从不渲染它)。 +- ui-skill 把菜单 pick 或回车提交的 `/name [args]` 认领进 invoke 事务(`matchEnter` 强等目录;未知名称保持为普通提示词)。已不可达的旧 `name` 引用 codec 被移除。 +- transcript(文本记录)依据来源元数据把这次注入物化为专用的 `skill-invocation` 节点(绝不从正文重新解析),并渲染为一个右对齐气泡:`/name` chip、尾随文本,以及收在 disclosure 之后的注入块。 + +同类产品调研(Pi、OpenCode、Claude Code、Kimi Code、Codex、DeepSeek-Reasonix——本地检出)结论一致:在每个产品上,用户显式触发都是以 user 角色消息做程序化注入、模型零参与;提示词引导的工具加载只存在于模型自主轨道上;disable-model-invocation 的对应物只把关模型侧表层。Kimi 的来源元数据渲染与 Claude Code/Kimi 的禁止重载提示词规则,可直接平移到 `MessageSource` 与目录那句话上。 + +## 考虑过的替代方案 + +- **`agent.inject()` 上下文注入**——没有同类产品先例;这次手势是一个用户轮次,不是环境通知,而且上下文行呈现、压缩(compaction)与归属全都不匹配。否决。 +- **宿主 `/skill ` 命令**(命令注册表,plan 模式先例)——两 token 的 UX、没有名称补全、仅限用户的 skill 在菜单里仍不可发现;按 cwd 的 skill 目录也与静态命令注册表格格不入。否决。 +- **客户端展开**(拉取正文、拼进提示词)——授权沦为可被绕过的客户端善意,日志失去调用语义,而且 Codex 已删除其等价机制(custom prompts)转向核心注入。否决。 +- **宿主提示词流水线扫描 `/name`**(Codex 的 `$name` core mentions)——重复了裁决层,还有吞掉普通行文中字面斜杠的风险;认领路径已经覆盖了这一需求。否决。 +- **每次注入一条前导语**(Kimi 的 `User activated the skill …`)——弃用,改为一次性的目录句子:同样的上下文、只支付一次,且注入块与工具结果保持逐字节一致。 + +## 后果 + +- 决策 21 的纯文本引用路径在提交处被取代:草稿仍承载纯文本与 lexicon 派生的 chip 视觉,但提交会认领进一次确定性注入,而不是把字面文本发出去再碰运气。模型自主轨道(目录 + `skill` 工具)不变。 +- 每一次用户可调用 skill 的调用现在都无条件付出其完整渲染正文的成本——这是确定性的代价,同类调研表明所有产品都在支付。 +- `skill-invocation` 来源搭乘 `user/message`,因此「模型可见 ⟺ 已记录」在不新增事件类型的情况下继续成立,回放与 UI 读取的是元数据而非文本标记。 +- TUI 与 ACP 之后可以为同样的语义采用 `skill.invoke`;在那之前,TUI 的客户端展开仍是它自己的路径。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 21f38d18e2..9f1bf08f9d 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1471,7 +1471,7 @@ export interface Config { } ``` -Source: [`packages/skill/skill/src/index.ts:170`](../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts:261`](../packages/skill/skill/src/index.ts) ## `@deepseek-ai/dsh-skill-local` @@ -2063,7 +2063,7 @@ export interface Config { } ``` -Source: [`packages/skill/tool-skill/src/index.ts:58`](../packages/skill/tool-skill/src/index.ts) +Source: [`packages/skill/tool-skill/src/index.ts:59`](../packages/skill/tool-skill/src/index.ts) ## `@deepseek-ai/dsh-tool-str-replace-editor` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 4ad9797262..55952b3591 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -677,7 +677,7 @@ A skill provider, runtime contribution, or provider-backed catalog may have chan 'skills/change'(): void ``` -Source: [`packages/skill/skill/src/index.ts:188`](../../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts:279`](../../packages/skill/skill/src/index.ts) ## `subagent/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 4a73dc06ad..4abd00c1fd 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1946,7 +1946,7 @@ async get(name: string, options: SkillLookupOptions = {}): Promisename
` invoked at submit time. The RPC rides the plugin's root-context connection captured at registration — the source never reads services off a per-call argument. The source implements no `matchSpace`/`matchEnter` hooks — skill references never enter command adjudication and ride ordinary prompts into the default sink. +Skill invocation source, browser half: registers the `/`-trigger `skill` source into `ctx.slash`. Ordinary-session candidates come from the `skill.list` RPC addressed by the per-call `ClientSessionContext` projection's `{sessionId}`, with the host resolving `cwd` from the session header. The host serves every user-invocable skill; a `modelInvocable: false` entry (a `disable-model-invocation` skill, whose only entry point is this path) wears the user-only marker as a description prefix in the active language. Catalog-addressed continuable children resolve no skill candidates locally because the existing skill RPC requires an attached session; viewing their persisted history must not activate them. Catalogs cache per ordinary session with a single-flight fetch; the scope-birth `warm` hook prewarms the session's entry and `connection/reset` clears everything. Results filter by `startsWith(query)`. + +A menu pick or an entered `/name [args]` line claims the composer into an args-tolerant `skill.invoke` transaction (`matchEnter` strong-waits the catalog; an unknown name answers undefined and stays a plain prompt). Submit trims the args, keeps blank args off the wire, and folds an RPC refusal into the composer's error outcome; the host renders the skill body and injects it as a user message before starting the turn, so invocation is deterministic for every user-invocable skill. The RPC rides the plugin's root-context connection captured at registration — the source never reads services off a per-call argument. Draft chip visuals still derive from the `lexicon` scan; the legacy `name` reference codec is gone (decision 21 removal cut) and `matchSpace` stays unimplemented — menu and enter own the skill flows. A failed `skill.list` throws from `candidates`, which the slash shell logs and folds into a silent menu-group drop — the menu shows only pending/ready states. @@ -14,23 +16,22 @@ The browser plugin also registers a keyed `skill` toolview in `conversation.chat ## Model Experience -### Skill reference text in the user prompt +### User-explicit skill invocation #### What the model sees -A picked candidate lands the literal `/name ` in the draft (decision 21: plain text, no `` tag); the text reaches the model verbatim inside the ordinary user message (`session.prompt`), with no dedicated content block, prompt section, or host-side expansion. The association with the actual skill is model-side and non-deterministic: the session prefix already carries the skill catalog (rendered by `dsh-tool-skill`), and the reference's name matching a catalog entry is what invites the model to load it. +A claimed invocation never ships the `/name` literal. The host (`skill.invoke`) renders the canonical `` block — the same `renderSkillContent` output the `skill` tool returns — appends the user's trailing text after a blank line, and injects the whole as one user-role message carrying the `skill-invocation` source, immediately starting a turn. Loading is deterministic: the model receives the full body without being asked to call the `skill` tool, and the catalog (rendered by `dsh-tool-skill`) tells it not to re-load an inline-injected skill. #### Token effect -Conditional and tiny: only a pick (or hand-typing the same text) adds the reference's characters to that one user message. Menu browsing and the candidate fetch add zero model tokens. +One invocation adds the rendered skill body plus the trailing text to that turn's user message — the same cost as the model loading the skill through the tool, paid unconditionally instead of at the model's discretion. Menu browsing and the candidate fetch add zero model tokens. #### KV Cache effect -Append-only: the reference is part of a new user message appended after the reusable history prefix. This package never edits earlier request tokens. +Append-only: the injected message lands after the reusable history prefix. This package never edits earlier request tokens. ## Known Limitations and Deferred Work - **Result-only history pages use the generic row** — keyed dispatch needs the paired call in the runtime window; pagination that leaves the call outside has no tool identity. This client presentation feature does not extend the history wire contract to recover it. -- **Non-deterministic skill loading** — the reference is a collaboration cue, not a guarantee; the model may ignore it. The rework path when hit rate proves insufficient (a host-side `context/skill-reference` guidance package, or full-text injection) sits in the design ledger; the wire text shape would not change. -- **First keystroke may race the prewarm** — the scope-birth warm launches the catalog fetch, but a menu opened before it settles shows no skill candidates for that keystroke. Accepted by design: skill references do not participate in enter adjudication, so nothing correctness-bearing waits on the catalog. +- **Enter waits on the catalog once** — `matchEnter` strong-waits the session's first catalog fetch before answering, so an enter racing a cold cache resolves against the settled catalog rather than silently missing. A menu opened before the prewarm settles still shows no skill candidates for that keystroke. - **Text is the truth** — the reference is plain draft text; a hand-typed identical token is the same reference. Chip visuals derive from the lexicon scan; no occurrence identity or position tracking (componentized chips are a ledger item). diff --git a/packages/client/ui-skill/README.zh.md b/packages/client/ui-skill/README.zh.md index 6eb6cbd3ae..3bbbc90186 100644 --- a/packages/client/ui-skill/README.zh.md +++ b/packages/client/ui-skill/README.zh.md @@ -2,7 +2,9 @@ [English](README.md) | 中文 -skill(技能)引用 source 的浏览器端:把 `/` 触发的 `skill` source 注册进 `ctx.slash`。普通会话的候选来自 `skill.list` RPC,以每次调用的 `ClientSessionContext` 投影中的 `{sessionId}` 寻址,host 从会话 header 解析 `cwd`。宿主返回模型可调用与用户可调用 skill 的交集,因为该浏览器路径插入的是模型引用,而不是直接加载正文。由目录寻址的可继续 subagent 在客户端解析为没有 skill 候选,因为现有 skill RPC 要求会话已挂载;查看其持久化历史不得激活它。目录按普通会话缓存,拉取走 single-flight;scope 创建时的 `warm` 钩子预热该会话的缓存项,`connection/reset` 清空全部缓存。结果按 `startsWith(query)` 过滤;pick 一个候选会把字面文本 `/name ` 经 slash 流水线落进草稿(决策 21 的纯文本引用),source 的 `codec` 拥有该引用的两种投影:`clipboardText` → `/name`,`serialize` → 提交时生成的模型形式 `name`。RPC 使用插件注册时捕获的根上下文连接——source 绝不从每次调用的参数上读取服务。source 不实现 `matchSpace`/`matchEnter` 钩子——skill 引用永不进入命令裁决,随普通提示词落入 default sink。 +skill(技能)调用 source 的浏览器端:把 `/` 触发的 `skill` source 注册进 `ctx.slash`。普通会话的候选来自 `skill.list` RPC,以每次调用的 `ClientSessionContext` 投影中的 `{sessionId}` 寻址,host 从会话 header 解析 `cwd`。宿主提供每一个用户可调用的 skill;`modelInvocable: false` 的条目(即 `disable-model-invocation` skill,此路径是其唯一入口)会以当前语言把仅限用户标记作为描述前缀带上。由目录寻址的可继续 subagent 在客户端解析为没有 skill 候选,因为现有 skill RPC 要求会话已挂载;查看其持久化历史不得激活它。目录按普通会话缓存,拉取走 single-flight;scope 创建时的 `warm` 钩子预热该会话的缓存项,`connection/reset` 清空全部缓存。结果按 `startsWith(query)` 过滤。 + +菜单 pick 或回车提交的一行 `/name [args]` 会把 composer 认领进一个容忍参数的 `skill.invoke` 事务(`matchEnter` 强等目录;未知名称应答 undefined,保持为普通提示词)。提交时会修剪参数、让空白参数不上协议,并把 RPC 拒绝折叠进 composer 的错误结局;宿主在开启轮次之前渲染 skill 正文并将其作为用户消息注入,因此对每一个用户可调用的 skill,调用都是确定性的。RPC 使用插件注册时捕获的根上下文连接——source 绝不从每次调用的参数上读取服务。草稿 chip 视觉仍由 `lexicon` 扫描派生;旧的 `name` 引用 codec 已经移除(决策 21 的移除裁定),`matchSpace` 保持不实现——skill 流程归菜单与回车所有。 `skill.list` 失败时 `candidates` 抛出异常,slash 壳层记录日志并折叠为静默的菜单组丢弃——菜单只显示 pending/ready 状态。 @@ -14,23 +16,22 @@ skill(技能)引用 source 的浏览器端:把 `/` 触发的 `skill` sourc ## 模型体验 -### 用户提示词中的 skill 引用文本 +### 用户显式 skill 调用 #### 模型看到的内容 -被 pick 的候选会把字面文本 `/name ` 落进草稿(决策 21:纯文本,无 `` 标签);该文本原样进入普通用户消息(`session.prompt`)到达模型,没有专用内容块、提示词 section 或 host 侧展开。与实际 skill 的关联在模型侧建立且具有非确定性:会话前缀已携带 skill 目录(由 `dsh-tool-skill` 渲染),引用名称与目录条目匹配,正是这一点引导模型去加载它。 +被认领的调用绝不会把字面文本 `/name` 发出去。宿主(`skill.invoke`)渲染规范的 `` 块——与 `skill` 工具返回的 `renderSkillContent` 输出相同——在一个空行之后追加用户的尾随文本,并把整体作为一条携带 `skill-invocation` 来源的 user 角色消息注入,随即开启一个轮次。加载是确定性的:模型无需被要求调用 `skill` 工具就能收到完整正文,目录(由 `dsh-tool-skill` 渲染)也会告诉它不要重新加载已内联注入的 skill。 #### Token 影响 -有条件且极小:只有 pick(或手动键入相同文本)会把引用的字符加进那一条用户消息。浏览菜单和拉取候选不会增加任何模型 token。 +一次调用会把渲染后的 skill 正文连同尾随文本加进该轮次的用户消息——成本与模型经由工具加载该 skill 相同,只是无条件支付,而非由模型自行裁量。浏览菜单和拉取候选不会增加任何模型 token。 #### KV Cache 影响 -仅追加:引用是追加在可复用历史前缀之后的新用户消息的一部分。该包绝不改写较早的请求 token。 +仅追加:注入的消息落在可复用历史前缀之后。该包绝不改写较早的请求 token。 ## 已知限制与暂缓事项 - **仅含结果的 history 页使用通用行**:键控分派要求配对调用位于 runtime 窗口内;分页将调用留在窗口外时,结果没有工具身份。这项客户端呈现功能不会为了恢复该身份而扩展 history 协议契约。 -- **skill 加载具有非确定性**:引用是协作线索,不是保证;模型可能忽略它。针对命中率不足情况的返工路径(host 侧 `context/skill-reference` 引导包,或全文注入)记录在设计台账中;协议中的文本形态不会改变。 -- **首次击键可能与预热竞速**:scope 创建时的预热会启动目录拉取,但目录落定之前打开的菜单,在那次击键下不会显示 skill 候选。这是设计上接受的取舍:skill 引用不参与回车裁决,因此没有任何攸关正确性的环节等待目录。 +- **回车对目录只等待一次**:`matchEnter` 在应答之前强等该会话的首次目录拉取,因此与冷缓存竞速的回车会对照已落定的目录解析,而不是静默错过。预热落定之前打开的菜单,在那次击键下仍不会显示 skill 候选。 - **文本是唯一依据**:引用是普通的草稿文本;手动键入的相同 token 就是同一个引用。chip 视觉由 lexicon 扫描派生;没有 occurrence 身份或位置跟踪(组件化 chip 是台账事项)。 diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 0a0131d292..017bd32970 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/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/host/apiproxy/README.md -README.md: 7ac7bdc6db2e2abbc60d1a8813e229c21ed39fe7 -README.zh.md: d6ece5caed752cf0cc59cc97017549ec2b1e66cb +README.md: 8d7a24b0b8b897d94ed29d5dc9ed6e9efb250fc6 +README.zh.md: c988b7540ba719d02e50d6da9595353c93766835 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 7ac7bdc6db..8d7a24b0b8 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -46,7 +46,7 @@ Directory picking delegates to the composed `ctx.directoryPicker` backend ([the `host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, and `xdg-open` on desktop Linux). For `.html`, `.htm`, `.xhtml`, and `.svg`, macOS and desktop Linux prefer a named default browser and fall back to that application handoff when none can be named. WSL translates every Linux path through `wslpath -w` and hands the resulting Windows/UNC path to Windows `Invoke-Item`, including browser-renderable documents, instead of assuming a Linux desktop association. The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`. -The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the browser's user-selected model-reference path, so it returns only skills that are both model-invocable and user-invocable; this domain has no direct skill-loading RPC. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. +The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the composer's invocation path: it returns every user-invocable skill with its `modelInvocable` flag, so menus can mark user-only (`disable-model-invocation`) entries whose only entry point this is. `skill.invoke` is the user-explicit loading RPC: it enforces user-invocation policy at this boundary (`skill-not-found` / `skill-not-invocable`), renders the canonical `` body via the shared `renderSkillContent`, appends the optional trailing `text`, injects the whole as a user-role message carrying the `skill-invocation` source, and starts a turn through the same route-served refusal gate as `session.prompt`. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preference `permission` and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select an arbitrary filesystem target. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. `llm.discoverModels` interrogates a provider endpoint the page is still drafting: `settingsNs` selects the adapter family that knows how to read the listing, and the endpoint, protocol, and key come from the form rather than from storage. It writes nothing — the reply is candidates, and only a later `settings.mutate` decides what a route serves — so its `apiKey` is the third payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`. The host never stores or returns it; like the other two it does ride the client's outgoing envelope, which `subscribeEnvelopes()` observers can see, and redacting that tap is a configuration-plane-wide change rather than this method's to make alone. Every refusal (an unserved namespace, a protocol with no readable listing, an unreachable endpoint, a rejected credential) folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `permission` or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index d6ece5caed..c988b7540b 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -46,7 +46,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr `host.openPath` 会用操作系统的默认应用打开一个文件系统路径(macOS 为 `open`,Windows 为 `Invoke-Item`,桌面 Linux 为 `xdg-open`)。对于 `.html`、`.htm`、`.xhtml` 与 `.svg`,macOS 和桌面 Linux 会优先使用能够确定的默认浏览器;无法确定时回退到上述应用交接。WSL 会通过 `wslpath -w` 转换每个 Linux 路径,并将所得 Windows/UNC 路径交给 Windows `Invoke-Item`,浏览器可渲染的文档也不例外,而非假定存在 Linux 桌面文件关联。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制。 -`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于浏览器中由用户选择的模型引用路径,因此仅返回模型和用户均可调用的 skill;该领域没有直接加载 skill 的 RPC。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 +`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于 composer 的调用路径:它返回每一个用户可调用的 skill 及其 `modelInvocable` 标志,让菜单能够标出仅限用户(`disable-model-invocation`)的条目——此处是这类条目唯一的入口。`skill.invoke` 是用户显式加载 RPC:它在此边界强制执行用户调用策略(`skill-not-found`/`skill-not-invocable`),经共享的 `renderSkillContent` 渲染规范的 `` 正文,追加可选的尾随 `text`,把整体作为一条携带 `skill-invocation` 来源的 user 角色消息注入,并经由与 `session.prompt` 相同的「路由是否有适配器在服务」拒绝闸门开启一个轮次。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 `settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `permission` 与产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个、也是最后一个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储或回显。host 从不存储或回传它;与另两者一样,它确实会搭乘客户端的出站信封,`subscribeEnvelopes()` 的观察者能看到——为该 tap 做脱敏是整个配置面的改动,而非本方法一家的事。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据)都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission` 或 `ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。 diff --git a/packages/skill/skill/README.i18n.yaml b/packages/skill/skill/README.i18n.yaml index 03d1b13fe8..fe29171cb3 100644 --- a/packages/skill/skill/README.i18n.yaml +++ b/packages/skill/skill/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/skill/skill/README.md -README.md: f538ae668ccff291be86348627d5547150f460df -README.zh.md: d61a242d01df1e22270c1cb049b922536654bbd6 +README.md: 0c1b2249d8c46ad9ce8097ceeda2bd988c92eb21 +README.zh.md: 8fed350d00433206aecdb32819adc81c82745869 diff --git a/packages/skill/skill/README.md b/packages/skill/skill/README.md index f538ae668c..0c1b2249d8 100644 --- a/packages/skill/skill/README.md +++ b/packages/skill/skill/README.md @@ -37,6 +37,10 @@ This package owns the `ctx.skills` interface. It does not know whether skills co | `{ modelInvocable: false, userInvocable: true }` | excluded | included | | `{ modelInvocable: false, userInvocable: false }` | excluded | excluded | +### Shared model-facing rendering + +`renderSkillContent(skill)` renders one loaded skill as the canonical `` block (escaped `name` attribute, resource hints, verbatim body). It is the single truth for both loading paths: `dsh-tool-skill` returns it as the `skill` tool result, and the host's user-explicit `skill.invoke` injects it as a user message, so the model sees one shape regardless of who initiated the load. `escapeText` is exported beside it for consumers embedding prose in the same markup frame. The package also declares the `skill-invocation` `MessageSource` kind ({ name, args? }) that user-explicit injection stamps on its messages — transcript consumers present the invocation from this metadata instead of re-parsing the body. + `isModelInvocable(skill)` and `isUserInvocable(skill)` read the matching positive field directly. `ctx.skills.get()` remains the trusted, policy-neutral loading primitive, so every user- or model-facing consumer must enforce the predicate that matches its surface before exposing or loading a skill. ## Provider Contract diff --git a/packages/skill/skill/README.zh.md b/packages/skill/skill/README.zh.md index d61a242d01..8fed350d00 100644 --- a/packages/skill/skill/README.zh.md +++ b/packages/skill/skill/README.zh.md @@ -37,6 +37,10 @@ | `{ modelInvocable: false, userInvocable: true }` | 排除 | 包含 | | `{ modelInvocable: false, userInvocable: false }` | 排除 | 排除 | +### 共享的面向模型渲染 + +`renderSkillContent(skill)` 把一个已加载 skill 渲染为规范的 `` 块(转义后的 `name` 属性、资源提示、原样正文)。它是两条加载路径的唯一真源:`dsh-tool-skill` 将其作为 `skill` 工具结果返回,宿主的用户显式 `skill.invoke` 将其作为用户消息注入,因此无论加载由谁发起,模型看到的都是同一种形态。`escapeText` 随之一并导出,供要在同一标记框架中嵌入文案的消费方使用。该包还声明 `skill-invocation` 这个 `MessageSource` kind({ name, args? }),用户显式注入会把它打在自己的消息上——transcript(文本记录)消费方依据这份元数据呈现该次调用,而不是重新解析正文。 + `isModelInvocable(skill)` 和 `isUserInvocable(skill)` 分别直接读取对应的正向字段。`ctx.skills.get()` 仍是受信且与策略无关的加载原语,因此每个面向用户或模型的消费方都必须先执行与自身接口匹配的判定,再暴露或加载 skill。 ## 提供方契约 diff --git a/packages/skill/tool-skill/README.i18n.yaml b/packages/skill/tool-skill/README.i18n.yaml index b57689d742..19fa44c67c 100644 --- a/packages/skill/tool-skill/README.i18n.yaml +++ b/packages/skill/tool-skill/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/skill/tool-skill/README.md -README.md: 8e0bff5d1c4853092d412b8f7f9528d4b00d9626 -README.zh.md: c6b815bef59eb1f14be0892078694f129366d004 +README.md: 5c6e592c670f324eb660dbe1fec168fd77e5b368 +README.zh.md: 202a621b1d4047c7d763de3b98c1a69c8c1ee1f7 diff --git a/packages/skill/tool-skill/README.md b/packages/skill/tool-skill/README.md index 8e0bff5d1c..5c6e592c67 100644 --- a/packages/skill/tool-skill/README.md +++ b/packages/skill/tool-skill/README.md @@ -36,7 +36,7 @@ Tool execution does not add a synthetic context message. Its freshly loaded resu #### What the model sees -If model-invocable skills exist and this exact `skill` tool is visible, the agent receives the catalog template below as a durable user-role message before the first request, with one data-dependent entry per sorted skill. Later membership, description, or visibility changes append a complete replacement using the same `` envelope; deleting every skill appends an empty envelope with an explicit instruction not to use older names. +If model-invocable skills exist and this exact `skill` tool is visible, the agent receives the catalog template below as a durable user-role message before the first request, with one data-dependent entry per sorted skill. Later membership, description, or visibility changes append a complete replacement using the same `` envelope; deleting every skill appends an empty envelope with an explicit instruction not to use older names. The template's closing sentence is the seam rule against double-loading: the host's user-explicit `skill.invoke` injects the same `renderSkillContent` output (shared from `@deepseek-ai/dsh-skill`) inline, and the catalog tells the model to follow that block instead of re-loading the skill through the tool; the replacement-catalog template carries the same sentence. ##### Skill catalog template @@ -49,6 +49,7 @@ A skill is a reusable set of task-specific instructions. The following skills ar If the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded. +A user may also invoke a skill directly; its block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill. ``` diff --git a/packages/skill/tool-skill/README.zh.md b/packages/skill/tool-skill/README.zh.md index c6b815bef5..202a621b1d 100644 --- a/packages/skill/tool-skill/README.zh.md +++ b/packages/skill/tool-skill/README.zh.md @@ -36,7 +36,7 @@ #### 模型看到的内容 -如果存在模型可调用 skill,且可见的正是这个 `skill` 工具,agent 会在第一个请求之前收到下方目录模板,其中包含每个已排序 skill 的一条随数据而定的条目。该目录是一条持久的用户角色消息。后续成员关系、描述或可见性的变化会使用同一个 `` 信封追加完整替换;删除所有 skill 时,会追加一个空信封,并明确指示不得使用旧名称。 +如果存在模型可调用 skill,且可见的正是这个 `skill` 工具,agent 会在第一个请求之前收到下方目录模板,其中包含每个已排序 skill 的一条随数据而定的条目。该目录是一条持久的用户角色消息。后续成员关系、描述或可见性的变化会使用同一个 `` 信封追加完整替换;删除所有 skill 时,会追加一个空信封,并明确指示不得使用旧名称。模板的结尾一句是防止双重加载的 seam 规则:宿主的用户显式 `skill.invoke` 会把同一份 `renderSkillContent` 输出(共享自 `@deepseek-ai/dsh-skill`)内联注入,目录则告诉模型遵循该块,而不是再经工具重新加载该 skill;替换目录模板携带同一句话。 ##### Skill 目录模板 @@ -49,6 +49,7 @@ A skill is a reusable set of task-specific instructions. The following skills ar If the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded. +A user may also invoke a skill directly; its block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill. ``` 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 055/109] 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 056/109] 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 057/109] 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 058/109] 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 69bd00ae76f0cc83b2f3837955cf4463cf53bfd8 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 10:28:30 +0800 Subject: [PATCH 059/109] chore(web): register the skill-user-invoke scenario in both typecheck planes The web app project excludes every e2e file (they are host-plane programs) and tsconfig.host.json includes them one by one; the new scenario joins both lists so it keeps typecheck coverage without dragging host sources into the client project. --- apps/web/tsconfig.json | 1 + tsconfig.host.json | 1 + 2 files changed, 2 insertions(+) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 528714a527..41224d21e4 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -57,6 +57,7 @@ "tests/markdown-inline-code-links.e2e.ts", "tests/queue-actions.e2e.ts", "tests/skill-invocation-policy.e2e.ts", + "tests/skill-user-invoke.e2e.ts", "tests/permission-policy-context.e2e.ts", "tests/access-confirmation.e2e.ts", "tests/shipped-composition.e2e.ts", diff --git a/tsconfig.host.json b/tsconfig.host.json index 6884839536..9a06566fa2 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -44,6 +44,7 @@ "apps/web/tests/markdown-inline-code-links.e2e.ts", "apps/web/tests/queue-actions.e2e.ts", "apps/web/tests/skill-invocation-policy.e2e.ts", + "apps/web/tests/skill-user-invoke.e2e.ts", "apps/web/tests/permission-policy-context.e2e.ts", "apps/web/tests/access-confirmation.e2e.ts", "apps/web/tests/shipped-composition.e2e.ts", From ae9d31d098f473fe3ed369043c5d9fedc0e8839d Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 10:46:58 +0800 Subject: [PATCH 060/109] review: pin off-value wire contract, scope compat inheritance to the entry's api, update the superseded note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings on #1977, each verified before acting: the 2026-08-03 declared-provider-catalog note is updated in place and cross-linked both ways now that reasoningEfforts/compat reopened half of its rejected alternative; resolveModelCompat inherits the catalog entry's compat only while the resolved api still is the entry's own, so a route-level api repoint no longer merges another protocol's shape as a completions base; the off-with-value promise gains a request-boundary test proving pi-ai reads thinkingLevelMap.off when the reasoning option is absent (and the catalog-level test name stops overclaiming); the cannot-stop-thinking wording narrows to what is actually enforced (no Off offered, explicit Off refused — an effortless request goes out bare); the z.const(null) comment attributes null passthrough to schemastery's nullable short-circuit; the baseten drift-gate claim names its verification source; and the layered-merge delete gap for dict keys is documented under Known Limitations with the atomic-leaf follow-up in #2003. --- ...-pi-ai-declared-provider-catalog.i18n.yaml | 4 +-- ...6-08-03-pi-ai-declared-provider-catalog.md | 6 ++-- ...8-03-pi-ai-declared-provider-catalog.zh.md | 6 ++-- ...per-model-reasoning-declarations.i18n.yaml | 4 +-- ...-pi-ai-per-model-reasoning-declarations.md | 6 ++-- ...-ai-per-model-reasoning-declarations.zh.md | 6 ++-- docs/user/guide/providers.i18n.yaml | 4 +-- docs/user/guide/providers.md | 2 +- docs/user/guide/providers.zh.md | 2 +- packages/llm/llm-pi-ai/README.i18n.yaml | 4 +-- packages/llm/llm-pi-ai/README.md | 3 +- packages/llm/llm-pi-ai/README.zh.md | 3 +- packages/llm/llm-pi-ai/src/catalog.ts | 10 ++++-- packages/llm/llm-pi-ai/src/config.ts | 14 ++++---- packages/llm/llm-pi-ai/tests/adapter.spec.ts | 33 +++++++++++++++++++ packages/llm/llm-pi-ai/tests/catalog.spec.ts | 2 +- 16 files changed, 75 insertions(+), 34 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.i18n.yaml index 9300571e28..2969995da6 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.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-03-pi-ai-declared-provider-catalog.md -2026-08-03-pi-ai-declared-provider-catalog.md: d75b6bdb91d60026636bf320f8c6625590849a41 -2026-08-03-pi-ai-declared-provider-catalog.zh.md: f8dba9900b1a7a3abcb16c70a35cc18f0c44219f +2026-08-03-pi-ai-declared-provider-catalog.md: f908eb6293b77680193fcd8f7be7a9089477855a +2026-08-03-pi-ai-declared-provider-catalog.zh.md: ce91abd6dc71f790c72766cd3f819096d596182c diff --git a/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.md b/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.md index d75b6bdb91..f908eb6293 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.md +++ b/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.md @@ -14,7 +14,7 @@ The adapter also streamed through `streamSimple` from `@earendil-works/pi-ai/com A provider route is a **declaration**, and the installed catalog is its default. `resolveProfiles` no longer checks route keys against `getBuiltinProviders()`. Instead each route resolves to a materialized model list plus the pi-ai `Provider` that serves it: -- `catalog.ts` merges the installed catalog under the profile's own entries. A profile's `models` list *replaces* the route's catalog (an absent or empty list serves it unchanged), and each entry defaults its unset fields from the installed model of the same `id`. Only the fields the harness consumes are configurable — `id`, `name`, `contextWindow`, `maxTokens`. Pricing and input modalities are absent from the surface because nothing reads them: `replay.ts` zeroes pi-ai's cost metadata and `context.ts` keeps only text blocks. Reasoning is absent for a different reason: a bare capability flag would make pi-ai advertise effort levels with no `thinkingLevelMap` to spell them, so it rides the installed entry or is absent. Materialization spreads the installed entry and overrides those four fields, rather than enumerating the result: an enumerated rebuild silently drops every `Model` field this package does not model, which is how `headers` went missing from an nvidia route once already. +- `catalog.ts` merges the installed catalog under the profile's own entries. A profile's `models` list *replaces* the route's catalog (an absent or empty list serves it unchanged), and each entry defaults its unset fields from the installed model of the same `id`. Only the fields the harness consumes are configurable — at this note's writing `id`, `name`, `contextWindow`, `maxTokens`; [[2026-08-08-pi-ai-per-model-reasoning-declarations]] later added `reasoningEfforts` and `compat`, which is also where the original "reasoning rides the installed entry or is absent" stance was revisited (a bare capability flag stays rejected; a full per-level declaration with wire spellings does not have its problem). Pricing and input modalities remain absent from the surface because nothing reads them: `replay.ts` zeroes pi-ai's cost metadata and `context.ts` keeps only text blocks. Materialization spreads the installed entry and overrides the configured fields, rather than enumerating the result: an enumerated rebuild silently drops every `Model` field this package does not model, which is how `headers` went missing from an nvidia route once already. - `provider.ts` builds the route's `Provider`. A catalog route that keeps its catalog protocol **reuses** the installed provider with `getModels()` replaced; every other route is built by `createProvider()` over a protocol table whose entries are the same `@earendil-works/pi-ai/api/*.lazy` factories pi-ai's own provider factories use. That table is narrower than pi-ai's full API set on purpose — it holds only protocols a profile can completely describe with a key, an endpoint, and headers, so Bedrock (SigV4 plus a region), Vertex (project, location, ADC), Azure (provider environment plus an api-version), and Codex (OAuth) are absent rather than offered as routes that cannot authenticate. Catalog routes still reach them through their own provider; only an explicit override is refused. - `adapter.ts` turns each resolution into an **immutable snapshot** — the profiles plus a `createModels()` collection holding those providers — and every operation captures a whole snapshot before its first `await`. - A model's **explicitly configured** `maxTokens` becomes the seam's `defaultMaxTokens`. The value inherited from the installed catalog does not: pi-ai requires `Model.maxTokens` as the model's output *capability*, while `defaultMaxTokens` is a cap the deployment chose to send on requests that name none, and materializing the former as the latter would start capping every request at a number nobody picked. @@ -35,7 +35,7 @@ The configurable-provider directory is now the installed catalog **joined with** pi-ai reports a model with no reasoning metadata as supporting the single level `off`, and the adapter used to pass that straight through. It reaches the seam as a one-item effort list, which every surface renders as a picker holding one selectable control — and that control is a lie: `off` becomes an *omitted* reasoning option at dispatch, byte-for-byte the request that naming no effort already produces. A provider whose own default is to think keeps thinking while the surface shows `off` selected. -`reasoningInfo` therefore omits the seam's `reasoning` field whenever `model.reasoning` is falsy. The condition is the model's own metadata, not where the model came from, so this covers every hand-declared model **and** the 251 installed-catalog models pi-ai marks as non-reasoning. Those previously offered the lone `off`; they now offer nothing, and the surface shows the provider default alone. Models that do carry reasoning metadata are untouched — their level list still crosses the seam unfiltered, `off` included, because there it selects between real alternatives. +`reasoningInfo` therefore omits the seam's `reasoning` field whenever `model.reasoning` is falsy. The condition is the model's own metadata, not where the model came from, so this covers every hand-declared model whose entry declares no `reasoningEfforts` ([[2026-08-08-pi-ai-per-model-reasoning-declarations]] made declared efforts carry that metadata) **and** the 251 installed-catalog models pi-ai marks as non-reasoning. Those previously offered the lone `off`; they now offer nothing, and the surface shows the provider default alone. Models that do carry reasoning metadata are untouched — their level list still crosses the seam unfiltered, `off` included, because there it selects between real alternatives. ### Credentials stay outside pi-ai @@ -50,7 +50,7 @@ A route's auth follows from that. A catalog route keeps the installed provider's - **Keep `createProvider()` but skip the `Models` collection**, streaming through `provider.streamSimple(model, ctx, {apiKey})`. Smallest diff and the credential path is untouched, but `createProvider`'s `auth` is a required field that this path never invokes — a required-by-signature implementation with no caller. It also leaves `refreshModels` needing a hand-built `RefreshModelsContext`, and keeps the adapter off the runtime pi-ai actually supports. - **Reuse the installed provider for catalog routes and `createProvider()` only for declared ones**, with no shared resolution. Zero risk to catalog behavior, but catalog materialization, endpoint override, and per-model configuration would each exist twice, and a catalog route that repoints its protocol would have to jump paths mid-resolution. The chosen split confines the asymmetry to provider construction, where it is forced by pi-ai not exposing a built provider's API implementations. - **Rebuild every route through `createProvider()`**, including catalog ones. Fully symmetric, but a built `Provider` does not expose its `api`, so the protocol table would become the ceiling on which providers work — Bedrock loads its Smithy module through a separate entry point and would silently stop working. -- **Expose pi-ai's whole `Model` shape** (cost, input modalities, `thinkingLevelMap`, `compat`). Maximum configurability, but no current consumer reads those fields, so a configured price or modality would change nothing while reading as supported. +- **Expose pi-ai's whole `Model` shape** (cost, input modalities, `thinkingLevelMap`, `compat`). Maximum configurability, but no current consumer read those fields then, so a configured price or modality would change nothing while reading as supported. The consumer-driven half of this arrived later: [[2026-08-08-pi-ai-per-model-reasoning-declarations]] opened reasoning (as `reasoningEfforts`, not a raw `thinkingLevelMap`) and the two reasoning-dispatch `compat` switches once selectors and dispatch actually consumed them; cost and modalities stay closed for the original reason. - **Keep one mutable `Models` collection and re-sync it.** Fewer allocations, and correct for every operation that resolves synchronously. It is exactly wrong for the one that does not: `stream()` awaits a credential between capturing its model and dispatching it. - **Simulate an atomic directory swap with dispose-then-register.** No seam change, and it works whenever the new set is valid — which is the case that never needed atomicity. diff --git a/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.zh.md b/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.zh.md index f8dba9900b..ce91abd6dc 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.zh.md @@ -14,7 +14,7 @@ Status: implemented 提供方路由是一份**声明**,已安装 catalog 是它的默认值。`resolveProfiles` 不再拿路由键去核对 `getBuiltinProviders()`,而是把每条路由解析成一份物化模型列表,外加服务它的 pi-ai `Provider`: -- `catalog.ts` 把已安装 catalog 合并到 profile 自身条目之下。profile 的 `models` 列表*替换*该路由的 catalog(列表缺席或为空则原样服务),每个条目从同 `id` 的已安装模型继承自身未设置的字段。只有 harness 会消费的字段可配置——`id`、`name`、`contextWindow`、`maxTokens`。定价与输入模态不出现在配置面,因为没有任何读取方:`replay.ts` 把 pi-ai 的成本元数据清零,`context.ts` 只保留文本块。推理缺席则是另一个理由:一个孤立的能力布尔量会让 pi-ai 公布出没有 `thinkingLevelMap` 可供拼写的档位,因此它沿用已安装条目或直接缺席。物化时以已安装条目铺底、再覆盖那四个字段,而不是逐字段枚举结果:枚举式重建会静默丢弃本包未建模的每一个 `Model` 字段——`headers` 就是这样从某条 nvidia 路由上消失过一次。 +- `catalog.ts` 把已安装 catalog 合并到 profile 自身条目之下。profile 的 `models` 列表*替换*该路由的 catalog(列表缺席或为空则原样服务),每个条目从同 `id` 的已安装模型继承自身未设置的字段。只有 harness 会消费的字段可配置——本 note 写就时为 `id`、`name`、`contextWindow`、`maxTokens`;[[2026-08-08-pi-ai-per-model-reasoning-declarations]] 之后加入了 `reasoningEfforts` 与 `compat`,当初「推理沿用已安装条目或直接缺席」的立场也在那里被重新审视(孤立的能力布尔量仍被拒绝;带 wire 拼写的逐档位完整声明没有它那个问题)。定价与输入模态仍不出现在配置面,因为没有任何读取方:`replay.ts` 把 pi-ai 的成本元数据清零,`context.ts` 只保留文本块。物化时以已安装条目铺底、再覆盖已配置的字段,而不是逐字段枚举结果:枚举式重建会静默丢弃本包未建模的每一个 `Model` 字段——`headers` 就是这样从某条 nvidia 路由上消失过一次。 - `provider.ts` 构造路由的 `Provider`。保持 catalog 协议不变的 catalog 路由会**复用**已安装提供方,只替换 `getModels()`;其余路由都由 `createProvider()` 基于一张协议表构造,表中条目正是 pi-ai 自己的提供方工厂所用的 `@earendil-works/pi-ai/api/*.lazy` factory。该表刻意窄于 pi-ai 的完整 API 集合——只保留 profile 能用密钥、端点与标头完整描述的协议,因此 Bedrock(SigV4 加 region)、Vertex(project、location、ADC)、Azure(提供方环境加 api-version)与 Codex(OAuth)不在其中,而不是被当作无法认证的路由提供出去。catalog 路由仍可经自己的 provider 抵达它们;被拒的只有显式覆盖。 - `adapter.ts` 把每次解析变成一份**不可变快照**——profiles 加上持有这些 provider 的 `createModels()` 集合——每个操作都在自己第一个 `await` 之前整体捕获一份。 - 模型**显式配置**的 `maxTokens` 会成为 seam 的 `defaultMaxTokens`;从已安装 catalog 继承来的那份不会:pi-ai 要求 `Model.maxTokens` 表示模型的输出**能力**,而 `defaultMaxTokens` 是部署选定、发给未点名上限的请求的那个值,把前者物化成后者会让每个请求都被一个无人选择的数字封顶。 @@ -35,7 +35,7 @@ Status: implemented pi-ai 把没有推理元数据的模型报告为只支持 `off` 一档,而适配器此前原样透传。它抵达 seam 时是一个单元素的 effort 列表,任何界面都会把它渲染成一个只有一项可选控件的选择器——而这个控件在撒谎:`off` 在派发时变成被*省略*的 reasoning 选项,与「不点名任何档位」产出的请求逐字节相同。自身默认就在思考的提供方会继续思考,界面却显示 `off` 已选中。 -因此只要 `model.reasoning` 为假,`reasoningInfo` 就省略 seam 的 `reasoning` 字段。判据是模型自身的元数据,而非模型的来源,所以它覆盖每一个手工声明的模型**以及** pi-ai 标记为不具备推理能力的那 251 个已安装 catalog 模型。它们此前提供那个孤零零的 `off`,现在什么也不提供,界面只剩提供方默认。携带推理元数据的模型不受影响——其档位列表仍不经筛选地穿过 seam、`off` 也在内,因为在那里它是在真实备选之间做选择。 +因此只要 `model.reasoning` 为假,`reasoningInfo` 就省略 seam 的 `reasoning` 字段。判据是模型自身的元数据,而非模型的来源,所以它覆盖条目未声明 `reasoningEfforts` 的每一个手工声明模型([[2026-08-08-pi-ai-per-model-reasoning-declarations]] 让声明的档位携带这份元数据)**以及** pi-ai 标记为不具备推理能力的那 251 个已安装 catalog 模型。它们此前提供那个孤零零的 `off`,现在什么也不提供,界面只剩提供方默认。携带推理元数据的模型不受影响——其档位列表仍不经筛选地穿过 seam、`off` 也在内,因为在那里它是在真实备选之间做选择。 ### 凭据留在 pi-ai 之外 @@ -50,7 +50,7 @@ pi-ai 的 `Models` 自带一套凭据概念——按提供方 id 索引的 `Cred - **保留 `createProvider()` 但不建 `Models` 集合**,改由 `provider.streamSimple(model, ctx, {apiKey})` 发起。改动最小且凭据路径原封不动,但 `createProvider` 的 `auth` 是必填字段,这条路上它永远不会被调用——一份因签名而必填、却没有调用方的实现。它还让 `refreshModels` 需要手工构造 `RefreshModelsContext`,并使适配器始终不在 pi-ai 真正支持的运行时上。 - **catalog 路由复用已安装提供方,只有声明式路由走 `createProvider()`**,且两者不共享解析。对 catalog 行为零风险,但 catalog 物化、端点覆盖与每模型配置这三件事都要各写两遍,而改指协议的 catalog 路由还得在解析中途跳到另一条路径。已采纳的拆法把不对称收敛在提供方构造这一处——那里的不对称是 pi-ai 不暴露已构造提供方的 API 实现所强加的。 - **让每条路由都经 `createProvider()` 重建**,包括 catalog 路由。完全对称,但已构造的 `Provider` 不暴露自己的 `api`,于是协议表会成为「哪些提供方能用」的天花板——Bedrock 经独立入口加载其 Smithy 模块,会因此静默失效。 -- **完整暴露 pi-ai 的 `Model` 形状**(成本、输入模态、`thinkingLevelMap`、`compat`)。可配置性最大,但这些字段当前没有任何读取方,因此配了价格或模态什么也不会改变,却看起来像是受支持的。 +- **完整暴露 pi-ai 的 `Model` 形状**(成本、输入模态、`thinkingLevelMap`、`compat`)。可配置性最大,但这些字段当时没有任何读取方,因此配了价格或模态什么也不会改变,却看起来像是受支持的。这条否决里由消费方驱动的那一半后来兑现了:[[2026-08-08-pi-ai-per-model-reasoning-declarations]] 在选择器与分派真正消费之后开放了推理(以 `reasoningEfforts` 的形态,而非裸 `thinkingLevelMap`)和两个推理分派 `compat` 开关;成本与模态仍因原有理由保持关闭。 - **保留单个可变 `Models` 集合并重新同步。** 分配更少,且对每个同步完成解析的操作都是正确的;唯独对那个不同步的操作恰恰是错的:`stream()` 会在捕获模型与派发模型之间 await 一次凭据。 - **用「先 dispose 再注册」模拟目录原子替换。** 无需改 seam,且在新集合有效时确实可用——而那正是从不需要原子性的那种情形。 diff --git a/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.i18n.yaml index 3b448f4cf1..3639c8da6b 100644 --- a/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.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-08-08-pi-ai-per-model-reasoning-declarations.md -2026-08-08-pi-ai-per-model-reasoning-declarations.md: 436b5f3f9f30c1bb1dc5816b12ce1596c5d01ec8 -2026-08-08-pi-ai-per-model-reasoning-declarations.zh.md: 47b34dfd270f90fef2802a00e3632777d5636a73 +2026-08-08-pi-ai-per-model-reasoning-declarations.md: b6264feeb724e3693078fa3fc3e3fc16ed01aacb +2026-08-08-pi-ai-per-model-reasoning-declarations.zh.md: 1b30f7e0c42974c777a535e133a47caa217e2e5e diff --git a/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.md b/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.md index 436b5f3f9f..b6264feeb7 100644 --- a/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.md +++ b/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.md @@ -6,15 +6,15 @@ English | [中文](2026-08-08-pi-ai-per-model-reasoning-declarations.zh.md) ## Problem -A hand-declared pi-ai route's models materialized with `reasoning: false`, so `getSupportedThinkingLevels` short-circuited to `["off"]`: the composer offered no effort picker for them, and the route-level `reasoning` default — the only reasoning knob a profile had — made every request to such a model fail with `UNSUPPORTED_REASONING_EFFORT` before network I/O. The same route-level knob was also the wrong altitude for catalog routes: one provider's models disagree about which levels they accept (deepseek ships `[off, high, max]` beside catalog models with `xhigh`), so a single per-route level could not be set without breaking part of the route, which is why the Models page stopped writing it entirely (#1860) and left `settings.yaml` with no way to align efforts per model. +Under the declared-provider catalog ([[2026-08-03-pi-ai-declared-provider-catalog]], which deliberately kept reasoning out of the configurable fields), a hand-declared pi-ai route's models materialized with `reasoning: false`, so `getSupportedThinkingLevels` short-circuited to `["off"]`: the composer offered no effort picker for them, and the route-level `reasoning` default — the only reasoning knob a profile had — made every request to such a model fail with `UNSUPPORTED_REASONING_EFFORT` before network I/O. The same route-level knob was also the wrong altitude for catalog routes: one provider's models disagree about which levels they accept (deepseek ships `[off, high, max]` beside catalog models with `xhigh`), so a single per-route level could not be set without breaking part of the route, which is why the Models page stopped writing it entirely (#1860) and left `settings.yaml` with no way to align efforts per model. Two adjacent gaps compounded this. pi-ai decides the reasoning *wire dialect* (`compat.thinkingFormat`, `compat.supportsReasoningEffort`) by recognizing the endpoint URL, and a private gateway's URL says nothing — a DeepSeek-dialect gateway was spoken to in the OpenAI dialect with no configuration that could correct it. And the only way to touch one catalog model was the `models` list, which *replaces* the served catalog: narrowing `gpt-5`'s levels meant restating all thirty-eight openai models or silently dropping thirty-seven. ## Decision -`PiAiModelProfile` gains `reasoningEfforts`: **each key is a level selectors offer, its value the spelling dispatch sends on the wire**. The declaration translates to pi-ai's `Model.reasoning` + `thinkingLevelMap` with all seven levels decided explicitly — declared levels carry their wire value, undeclared levels are pinned `null` — so the profile author never needs pi-ai's asymmetric defaulting rule (absent means "supported" for the five base levels but "unsupported" for `xhigh`/`max`). `off` is the one three-state key: left out, thinking cannot be turned off; declared valueless, Off is offered and dispatch sends nothing (the `deepseek` dialect sends `thinking: {type: "disabled"}`); declared with a value, that value goes on the wire. `false` declares a non-reasoning model; an empty declaration is refused rather than guessed at. The spelling for "disable" is `false` rather than `{}` because schemastery materializes an absent dict as `{}` — only a `z.union([z.const(false), dict])` keeps absent, disabled, and declared distinguishable, and a bare `reasoningEfforts:` (YAML null) slips through that union unvalidated, so resolution refuses it explicitly. +`PiAiModelProfile` gains `reasoningEfforts`: **each key is a level selectors offer, its value the spelling dispatch sends on the wire**. The declaration translates to pi-ai's `Model.reasoning` + `thinkingLevelMap` with all seven levels decided explicitly — declared levels carry their wire value, undeclared levels are pinned `null` — so the profile author never needs pi-ai's asymmetric defaulting rule (absent means "supported" for the five base levels but "unsupported" for `xhigh`/`max`). `off` is the one three-state key: left out, no Off is offered and an explicit Off request is refused (an effortless request still goes out bare, leaving the provider its default); declared valueless, Off is offered and dispatch sends nothing (the `deepseek` dialect sends `thinking: {type: "disabled"}`); declared with a value, that value goes on the wire. `false` declares a non-reasoning model; an empty declaration is refused rather than guessed at. The spelling for "disable" is `false` rather than `{}` because schemastery materializes an absent dict as `{}` — only a `z.union([z.const(false), dict])` keeps absent, disabled, and declared distinguishable, and a bare `reasoningEfforts:` (YAML null) slips through that union unvalidated, so resolution refuses it explicitly. -`compat.thinkingFormat` and `compat.supportsReasoningEffort` become configurable at two levels — route (its models' default) and model (winning per field) — resolving model → route → installed catalog entry → pi-ai's URL guess. They exist only on `openai-completions` (pi-ai types them nowhere else): a model-level switch on another protocol fails resolution, a route-level default skips such models, and a route with no completions model at all is refused. The two `chat-template` formats stay withheld for want of `chatTemplateKwargs`. Both enums are pinned to pi-ai's types through `Record` drift gates, so the pi-ai upgrade that adds a format (0.84 added `baseten`) fails compilation until the new member is classified. +`compat.thinkingFormat` and `compat.supportsReasoningEffort` become configurable at two levels — route (its models' default) and model (winning per field) — resolving model → route → installed catalog entry → pi-ai's URL guess. They exist only on `openai-completions` (pi-ai types them nowhere else): a model-level switch on another protocol fails resolution, a route-level default skips such models, and a route with no completions model at all is refused. The two `chat-template` formats stay withheld for want of `chatTemplateKwargs`. Both enums are pinned to pi-ai's types through `Record` drift gates, so a pi-ai upgrade that adds a format fails compilation until the new member is classified (verified against the published 0.84.1 tarball, whose `thinkingFormat` union adds `baseten` over the pinned 0.82.1). `modelOverrides` reshapes individual catalog models without replacing the served set: key = catalog model id, value = a `models` entry minus `id`, materialized by handing the override to the existing entry path so capacities, efforts, compat, and request-default semantics stay identical. Unlike Pi's own config layer, which ignores unknown ids, every override that lands nowhere is refused — beside a `models` list, on a hand-declared route, naming an unknown model, or smuggling an `id` in the value (the schema passes unknown keys through, and a smuggled id would quietly rename the model). diff --git a/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.zh.md b/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.zh.md index 47b34dfd27..1b30f7e0c4 100644 --- a/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.zh.md @@ -6,15 +6,15 @@ Status: implemented ## 问题 -手工声明的 pi-ai 路由,其模型物化出来就带着 `reasoning: false`,于是 `getSupportedThinkingLevels` 短路成 `["off"]`:输入框不为它们提供档位选择器,而路由级的 `reasoning` 默认值——当时 profile 仅有的推理旋钮——让发往这类模型的每个请求都在网络 I/O 之前以 `UNSUPPORTED_REASONING_EFFORT` 失败。同一个路由级旋钮对 catalog 路由来说也放错了层级:同一提供方下各模型接受的档位并不一致(deepseek 自带 `[off, high, max]`,旁边就是带 `xhigh` 的 catalog 模型),单个路由级档位怎么设都会弄坏路由的一部分——这正是模型页彻底停写它的原因(#1860),而 `settings.yaml` 也因此没有了任何按模型对齐档位的办法。 +在声明式提供方 catalog([[2026-08-03-pi-ai-declared-provider-catalog]],它刻意把推理排除在可配置字段之外)之下,手工声明的 pi-ai 路由,其模型物化出来就带着 `reasoning: false`,于是 `getSupportedThinkingLevels` 短路成 `["off"]`:输入框不为它们提供档位选择器,而路由级的 `reasoning` 默认值——当时 profile 仅有的推理旋钮——让发往这类模型的每个请求都在网络 I/O 之前以 `UNSUPPORTED_REASONING_EFFORT` 失败。同一个路由级旋钮对 catalog 路由来说也放错了层级:同一提供方下各模型接受的档位并不一致(deepseek 自带 `[off, high, max]`,旁边就是带 `xhigh` 的 catalog 模型),单个路由级档位怎么设都会弄坏路由的一部分——这正是模型页彻底停写它的原因(#1860),而 `settings.yaml` 也因此没有了任何按模型对齐档位的办法。 两个相邻的缺口让问题雪上加霜。pi-ai 靠识别端点 URL 来决定推理的*协议方言*(`compat.thinkingFormat`、`compat.supportsReasoningEffort`),而私有网关的 URL 什么也说明不了——说 DeepSeek 方言的网关只会收到 OpenAI 方言的请求,且没有任何配置能更正它。另外,想动单个 catalog 模型,唯一的手段是 `models` 列表,而它会*替换*所服务的 catalog:收窄 `gpt-5` 的档位,意味着要么重述全部三十八个 openai 模型,要么静默丢掉三十七个。 ## 决策 -`PiAiModelProfile` 新增 `reasoningEfforts`:**每个键是选择器提供的一个档位,其值是分派在协议中发送的拼写**。该声明会转换为 pi-ai 的 `Model.reasoning` + `thinkingLevelMap`,七个档位全部显式决定——已声明的档位携带自己的协议值,未声明的档位一律固定为 `null`——因此 profile 作者永远不需要了解 pi-ai 那条不对称的默认规则(键缺席对五个基础档位意味着「支持」,对 `xhigh`/`max` 却意味着「不支持」)。`off` 是唯一的三态键:不写,思考就关不掉;声明而不给值,则提供 Off,分派什么也不发送(`deepseek` 方言发送 `thinking: {type: "disabled"}`);声明并给值,该值就在协议中发送。`false` 声明一个不具备推理能力的模型;空声明会被拒绝,而不是去猜。「禁用」的拼写取 `false` 而非 `{}`,因为 schemastery 会把缺席的字典物化成 `{}`——只有 `z.union([z.const(false), dict])` 才能让缺席、禁用与已声明三态保持可区分;而裸写的 `reasoningEfforts:`(YAML null)会不经校验地从该 union 溜过去,因此解析对它显式拒绝。 +`PiAiModelProfile` 新增 `reasoningEfforts`:**每个键是选择器提供的一个档位,其值是分派在协议中发送的拼写**。该声明会转换为 pi-ai 的 `Model.reasoning` + `thinkingLevelMap`,七个档位全部显式决定——已声明的档位携带自己的协议值,未声明的档位一律固定为 `null`——因此 profile 作者永远不需要了解 pi-ai 那条不对称的默认规则(键缺席对五个基础档位意味着「支持」,对 `xhigh`/`max` 却意味着「不支持」)。`off` 是唯一的三态键:不写,选择器不提供 Off,显式请求 Off 会被拒绝(不点名档位的请求仍会不带参数地发出,提供方保留自己的默认行为);声明而不给值,则提供 Off,分派什么也不发送(`deepseek` 方言发送 `thinking: {type: "disabled"}`);声明并给值,该值就在协议中发送。`false` 声明一个不具备推理能力的模型;空声明会被拒绝,而不是去猜。「禁用」的拼写取 `false` 而非 `{}`,因为 schemastery 会把缺席的字典物化成 `{}`——只有 `z.union([z.const(false), dict])` 才能让缺席、禁用与已声明三态保持可区分;而裸写的 `reasoningEfforts:`(YAML null)会不经校验地从该 union 溜过去,因此解析对它显式拒绝。 -`compat.thinkingFormat` 与 `compat.supportsReasoningEffort` 变为两级可配置——路由级(作为其模型的默认值)与模型级(逐字段胜出)——解析顺序为模型 → 路由 → 已安装 catalog 条目 → pi-ai 按 URL 得出的猜测。两者只存在于 `openai-completions` 上(pi-ai 也只在这一协议上为它们建了类型):在其他协议的模型上设模型级开关会使解析失败,路由级默认值会跳过这类模型,而完全没有 completions 模型的路由则被拒绝。两个 `chat-template` 格式因缺 `chatTemplateKwargs` 而继续保持不开放。两个枚举都经 `Record` 漂移门禁钉在 pi-ai 的类型上,因此新增格式的 pi-ai 升级(0.84 加入了 `baseten`)会编译失败,直到新成员被归类。 +`compat.thinkingFormat` 与 `compat.supportsReasoningEffort` 变为两级可配置——路由级(作为其模型的默认值)与模型级(逐字段胜出)——解析顺序为模型 → 路由 → 已安装 catalog 条目 → pi-ai 按 URL 得出的猜测。两者只存在于 `openai-completions` 上(pi-ai 也只在这一协议上为它们建了类型):在其他协议的模型上设模型级开关会使解析失败,路由级默认值会跳过这类模型,而完全没有 completions 模型的路由则被拒绝。两个 `chat-template` 格式因缺 `chatTemplateKwargs` 而继续保持不开放。两个枚举都经 `Record` 漂移门禁钉在 pi-ai 的类型上,因此新增格式的 pi-ai 升级会编译失败,直到新成员被归类(对照已发布的 0.84.1 tarball 验证过:其 `thinkingFormat` 联合类型相对钉住的 0.82.1 新增了 `baseten`)。 `modelOverrides` 就地重塑单个 catalog 模型而不替换所服务的集合:键 = catalog 模型 id,值 = 去掉 `id` 的 `models` 条目,物化时把覆盖交给既有的条目路径,因此容量、档位、compat 与请求默认值语义完全一致。与忽略未知 id 的 Pi 自有配置层不同,凡是落不到任何地方的覆盖都会被拒绝——与 `models` 列表并存、写在手工声明的路由上、点名未知模型,或在值里夹带 `id`(schema 会放行未知键,被夹带的 id 会悄悄把模型改名)。 diff --git a/docs/user/guide/providers.i18n.yaml b/docs/user/guide/providers.i18n.yaml index 787f3ec1d6..38df7fb986 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: c2c578b8489621004d5ceab8330f63b4e371b1f6 -providers.zh.md: df50cdd39321b7267089ca12a68a42696f7f8f66 +providers.md: 8b52044e64411e3081d56c1ee1849d0b24cd1cda +providers.zh.md: f4a42a4093d253b4b230e4a838ba275a0ce58ac9 diff --git a/docs/user/guide/providers.md b/docs/user/guide/providers.md index c2c578b848..8b52044e64 100644 --- a/docs/user/guide/providers.md +++ b/docs/user/guide/providers.md @@ -106,7 +106,7 @@ Reshaping a few catalog models while keeping the rest is `modelOverrides`' job: The configurable model fields are `id`, `name`, `contextWindow`, `maxTokens`, `reasoningEfforts`, and `compat`. Pricing and input modalities have no consumer and ride the installed entry. -**Declare reasoning levels per model.** `reasoningEfforts` lists the levels a model offers: each key appears in the composer's effort picker, and its value is what dispatch sends on the wire — `high: high` passes the name through, `max: ultra` renames it for a gateway with its own vocabulary. A level you leave out is not offered. `off` is special: declared without a value, Off appears in the picker and selecting it sends nothing; left out entirely, the model cannot stop thinking. `reasoningEfforts: false` declares a non-reasoning model, which is also how you strip reasoning from a catalog model your gateway cannot serve. Without this field a custom model does not reason and a catalog model keeps its catalog levels. +**Declare reasoning levels per model.** `reasoningEfforts` lists the levels a model offers: each key appears in the composer's effort picker, and its value is what dispatch sends on the wire — `high: high` passes the name through, `max: ultra` renames it for a gateway with its own vocabulary. A level you leave out is not offered. `off` is special: declared without a value, Off appears in the picker and selecting it sends nothing; left out entirely, the picker offers no Off and requests carry no off switch — the provider's own default decides. `reasoningEfforts: false` declares a non-reasoning model, which is also how you strip reasoning from a catalog model your gateway cannot serve. Without this field a custom model does not reason and a catalog model keeps its catalog levels. **Pick the reasoning dialect.** How a level travels — plain `reasoning_effort`, DeepSeek's `thinking: {type}` plus effort, and so on — is normally guessed from the endpoint URL, and a private gateway's URL says nothing, so a DeepSeek-style gateway would be spoken to in the OpenAI dialect. `compat.thinkingFormat` sets the dialect explicitly, and `compat.supportsReasoningEffort: false` holds the parameter back from an endpoint that rejects it; both work on the route (its models' default) or per model, for `openai-completions` routes only. diff --git a/docs/user/guide/providers.zh.md b/docs/user/guide/providers.zh.md index df50cdd393..f4a42a4093 100644 --- a/docs/user/guide/providers.zh.md +++ b/docs/user/guide/providers.zh.md @@ -106,7 +106,7 @@ settings 段落**逐个提供方**地盖在 `cordis.yml` 的同名配置之上 可配置的模型字段是 `id`、`name`、`contextWindow`、`maxTokens`、`reasoningEfforts` 与 `compat`。定价与输入模态没有消费方,随内置目录条目走。 -**按模型声明推理档位。** `reasoningEfforts` 列出模型提供的档位:每个键都会出现在输入框的档位选择器里,其值是分派在协议中实际发送的内容——`high: high` 原样透传名称,`max: ultra` 则为使用自有词汇的网关改名。没写的档位不会被提供。`off` 比较特殊:声明而不给值,选择器里会出现 Off,选中它时什么也不发送;完全不写,模型就无法停止思考。`reasoningEfforts: false` 声明一个不具备推理能力的模型,这也是从网关服务不了的目录模型上剥除推理的办法。不写这个字段,自定义模型不推理,目录模型保留目录给出的档位。 +**按模型声明推理档位。** `reasoningEfforts` 列出模型提供的档位:每个键都会出现在输入框的档位选择器里,其值是分派在协议中实际发送的内容——`high: high` 原样透传名称,`max: ultra` 则为使用自有词汇的网关改名。没写的档位不会被提供。`off` 比较特殊:声明而不给值,选择器里会出现 Off,选中它时什么也不发送;完全不写,选择器不提供 Off,请求也不携带关闭开关——由提供方自己的默认行为决定。`reasoningEfforts: false` 声明一个不具备推理能力的模型,这也是从网关服务不了的目录模型上剥除推理的办法。不写这个字段,自定义模型不推理,目录模型保留目录给出的档位。 **选定推理方言。** 档位如何在协议中传输——单独一个 `reasoning_effort`、DeepSeek 的 `thinking: {type}` 加档位,诸如此类——通常靠端点 URL 来猜,而私有网关的 URL 什么也说明不了,于是 DeepSeek 风格的网关只会收到 OpenAI 方言的请求。`compat.thinkingFormat` 用来显式指定方言,`compat.supportsReasoningEffort: false` 则让该参数不再发给拒绝它的端点;两者既可设在路由上(作为其模型的默认值),也可按模型设置,且仅适用于 `openai-completions` 路由。 diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 05c7376c8e..1fe2902388 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: 196c347dc557d3d3993756e165f45c9212cd2d36 -README.zh.md: cee6fce7bd13d9da5fdbe5312c7c7e7f4ddf8ab5 +README.md: c5ebca23ccb4162b65a6e18132970eaf01a50b84 +README.zh.md: f916462bca915bea37c59f7a33a08e1dcc18c4c7 diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 196c347dc5..c5ebca23cc 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -83,7 +83,7 @@ A profile's `models` list *replaces* the route's installed catalog rather than e `reasoningEfforts` declares a model's selectable thinking levels: each key is a level selectors offer, its value the spelling dispatch sends on the wire, so `high: high` passes the canonical name through while `max: ultra` renames it for a gateway with its own vocabulary. Keys come from pi-ai's level set (`off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`); a level not declared is not offered. Omitting the field keeps the installed catalog entry's capability (a hand-declared model has none and does not reason); `false` declares a non-reasoning model, which is how a profile strips reasoning from a catalog model its gateway cannot serve; an empty declaration is refused rather than guessing between those two meanings. -The declaration translates to pi-ai's `Model.reasoning` + `thinkingLevelMap` with every level decided explicitly — undeclared levels are pinned unsupported rather than left to pi-ai's own defaulting, which is asymmetric (an absent key means "supported" for the five base levels but "unsupported" for `xhigh`/`max`) and which a profile author should not need to know. `off` is the one three-state key: left out, the model cannot stop thinking and selectors offer no Off; declared with no value (`off:`), Off is offered and selecting it sends nothing — for the `deepseek` dialect an explicit `thinking: {type: "disabled"}` — which also covers a request naming no effort at all; declared with a value (`off: none`), that value goes on the wire as the effort parameter. There is no spelling for restoring a catalog map key to "unset": the declaration is the whole offer, so restate the catalog levels you keep. +The declaration translates to pi-ai's `Model.reasoning` + `thinkingLevelMap` with every level decided explicitly — undeclared levels are pinned unsupported rather than left to pi-ai's own defaulting, which is asymmetric (an absent key means "supported" for the five base levels but "unsupported" for `xhigh`/`max`) and which a profile author should not need to know. `off` is the one three-state key: left out, selectors offer no Off and an explicit Off request is refused — a request naming no effort still goes out without the parameter, so what the provider then does is its own default; declared with no value (`off:`), Off is offered and selecting it sends nothing — for the `deepseek` dialect an explicit `thinking: {type: "disabled"}` — which also covers a request naming no effort at all; declared with a value (`off: none`), that value goes on the wire as the effort parameter. There is no spelling for restoring a catalog map key to "unset": the declaration is the whole offer, so restate the catalog levels you keep. ### Reasoning-dispatch compat switches @@ -186,6 +186,7 @@ Recorded response content appends to the next request and does not invalidate it ## Known Limitations and Deferred Work - **Settings can add or override routes, not remove composition routes** — the user layer merges over the composition `base`, so deleting a `cordis.yml`-provided provider is a composition change; `replace` on the namespace only resets the user layer. +- **The layered merge has no delete for dict keys** — the settings seam merges the composition `base` and the user layer per key, recursively, so a `reasoningEfforts` level, `modelOverrides` entry, or `compat` field the base declares cannot be removed by the user layer, only overridden — and for `reasoningEfforts` absence *is* the meaning ("not offered"), so a base-declared level stays offered. A `models` list is an array and replaces wholesale, which is the workaround: declare the model there instead. Atomic-leaf merge semantics at the settings seam are tracked in [#2003](https://github.com/deepseek-harness/deepseek-harness/issues/2003). - **`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. diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index cee6fce7bd..f916462bca 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -83,7 +83,7 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩 `reasoningEfforts` 声明模型可选的思考级别:每个键是选择器提供的一个档位,其值是分派在协议中发送的拼写,因此 `high: high` 原样透传规范名称,而 `max: ultra` 则为使用自有词汇的网关改名。键取自 pi-ai 的档位集合(`off`、`minimal`、`low`、`medium`、`high`、`xhigh`、`max`);未声明的档位不会被提供。省略该字段会保留已安装 catalog 条目的能力(手工声明的模型没有这份能力,也不推理);`false` 声明一个不具备推理能力的模型,profile 正是以此从其网关无法服务的 catalog 模型上剥除推理;空声明会被拒绝,而不是在这两种含义之间去猜。 -该声明会转换为 pi-ai 的 `Model.reasoning` + `thinkingLevelMap`,其中每个档位都被显式决定——未声明的档位一律固定为不支持,而不是留给 pi-ai 自己的默认规则:那套规则并不对称(键缺席对五个基础档位意味着「支持」,对 `xhigh`/`max` 却意味着「不支持」),也本不该要求 profile 作者了解。`off` 是唯一的三态键:不写它,模型就无法停止思考,选择器也不提供 Off;声明而不给值(`off:`),则会提供 Off,选中它时什么也不发送——对 `deepseek` 方言则是一个显式的 `thinking: {type: "disabled"}`——这同时覆盖完全不点名任何档位的请求;声明并给值(`off: none`),该值就会作为档位参数在协议中发送。没有任何写法能把 catalog 映射中的键恢复为「未设置」:这份声明就是对外提供的全部,因此把你要保留的 catalog 档位重述出来。 +该声明会转换为 pi-ai 的 `Model.reasoning` + `thinkingLevelMap`,其中每个档位都被显式决定——未声明的档位一律固定为不支持,而不是留给 pi-ai 自己的默认规则:那套规则并不对称(键缺席对五个基础档位意味着「支持」,对 `xhigh`/`max` 却意味着「不支持」),也本不该要求 profile 作者了解。`off` 是唯一的三态键:不写它,选择器不提供 Off,显式请求 Off 会被拒绝——不点名任何档位的请求仍会在不带该参数的情况下发出,提供方随后做什么是它自己的默认行为;声明而不给值(`off:`),则会提供 Off,选中它时什么也不发送——对 `deepseek` 方言则是一个显式的 `thinking: {type: "disabled"}`——这同时覆盖完全不点名任何档位的请求;声明并给值(`off: none`),该值就会作为档位参数在协议中发送。没有任何写法能把 catalog 映射中的键恢复为「未设置」:这份声明就是对外提供的全部,因此把你要保留的 catalog 档位重述出来。 ### 推理分派的 compat 开关 @@ -186,6 +186,7 @@ pi-ai 事件会变为 harness 推理、文本、工具调用、usage 与 finish ## 已知限制与暂缓事项 - **settings 能新增或覆盖路由,但不能移除组合路由**:用户层合并在组合 `base` 之上,因此删除 `cordis.yml` 提供的提供方属于组合变更;对该 namespace 执行 `replace` 只会重置用户层。 +- **分层合并对字典键没有删除语义**:settings seam 把组合 `base` 与用户层按键递归合并,因此 base 声明的某个 `reasoningEfforts` 档位、`modelOverrides` 条目或 `compat` 字段,用户层只能覆盖、无法移除——而 `reasoningEfforts` 里缺席本身*就是*语义(「不提供」),于是 base 声明过的档位会一直被提供。`models` 列表是数组、整体替换,这也是规避写法:把该模型改到那里声明。settings seam 的原子叶合并语义在 [#2003](https://github.com/deepseek-harness/deepseek-harness/issues/2003) 跟进。 - **`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` 并把全部模型一起迁过去。把该提供方拆成两个路由键是变通办法。 diff --git a/packages/llm/llm-pi-ai/src/catalog.ts b/packages/llm/llm-pi-ai/src/catalog.ts index 3285d1595a..8f1bc1a43c 100644 --- a/packages/llm/llm-pi-ai/src/catalog.ts +++ b/packages/llm/llm-pi-ai/src/catalog.ts @@ -347,9 +347,13 @@ function resolveModelCompat( } return {} } - // The installed entry's compat matches its own api, so on an - // openai-completions model it is the completions shape. - const inherited: OpenAICompletionsCompat | undefined = base?.compat + // The installed entry's compat matches the entry's OWN api — a route-level + // `api` repoint (an anthropic catalog served through an OpenAI-compatible + // gateway) leaves `base.compat` in the other protocol's shape, so it is + // inherited only while the resolved api still is the entry's. A repointed + // model starts from pi-ai's baseURL-derived detection instead, which is + // what a protocol change means for every other compat field too. + const inherited: OpenAICompletionsCompat | undefined = base?.api === api ? base.compat : undefined return { compat: { ...inherited, diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index 2b824f4cae..7bce3b6376 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -161,12 +161,14 @@ const compatProfile: z = z.object({ }) /** - * Keys are the offered levels, values their wire spellings. `z.const(null)` - * keeps a valueless key (`off:`) alive through validation — only resolution - * decides which levels may leave the value empty, so the diagnostic can name - * the route and model. The assertion narrows schemastery's `Dict`, which - * types every literal key as required; dict validation is per-present-key, so - * the runtime shape is the partial record. + * Keys are the offered levels, values their wire spellings. A valueless key + * (`off:`) survives validation because schemastery passes nullable data + * through before any member schema runs — `z.const(null)` only shapes the + * error for non-null wrong values and what a configuration surface renders. + * Only resolution decides which levels may leave the value empty, so the + * diagnostic can name the route and model. The assertion narrows + * schemastery's `Dict`, which types every literal key as required; dict + * validation is per-present-key, so the runtime shape is the partial record. */ const reasoningEfforts = z.dict( z.union([z.string(), z.const(null)]), diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index 6f8c2ab116..d2e101505d 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -524,6 +524,39 @@ describe('provider profile lifecycle', () => { expect(server.requests[1]).not.toHaveProperty('reasoning_effort') }) + it('sends a declared off value as the effort parameter instead of omitting it', async () => { + vi.stubEnv('PI_TEST_KEY', 'test-key') + const server = await mockServer([{ events: textEvents }]) + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(LlmPiAi, { + providers: { + 'acme-gateway': { + apiKeyEnv: 'PI_TEST_KEY', + api: 'openai-completions', + baseURL: `${server.url}/v1`, + models: [{ + id: 'acme-think', + contextWindow: 65_536, + maxTokens: 4096, + reasoningEfforts: { off: 'none', high: 'high' }, + }], + }, + }, + }) + + // The adapter strips a selected Off to "no reasoning option", and pi-ai's + // dispatch reads thinkingLevelMap.off exactly then — so the declared value + // still reaches the wire, which is the README's promise for `off: none`. + await assemble(ctx, { + provider: 'acme-gateway', + model: 'acme-think', + reasoningEffort: ReasoningEffortId('off'), + messages: [], + }) + expect(server.requests[0]).toMatchObject({ reasoning_effort: 'none' }) + }) + it('holds back reasoning_effort when the endpoint cannot take it', async () => { vi.stubEnv('PI_TEST_KEY', 'test-key') const server = await mockServer([{ events: textEvents }]) diff --git a/packages/llm/llm-pi-ai/tests/catalog.spec.ts b/packages/llm/llm-pi-ai/tests/catalog.spec.ts index 790e59c260..14cb10df76 100644 --- a/packages/llm/llm-pi-ai/tests/catalog.spec.ts +++ b/packages/llm/llm-pi-ai/tests/catalog.spec.ts @@ -520,7 +520,7 @@ describe('per-model reasoning efforts', () => { expect(getSupportedThinkingLevels(model)).toEqual(['off', 'low', 'high', 'max']) }) - it('sends a declared off value on the wire instead of omitting the parameter', () => { + it('keeps a declared off value in the map for dispatch to send', () => { const model = modelOf(declared([{ id: 'm', reasoningEfforts: { off: 'none', high: 'high' } }])) expect(model.thinkingLevelMap?.off).toBe('none') expect(getSupportedThinkingLevels(model)).toEqual(['off', 'high']) From e5d0089d5be77ac193defdd8a43c849222f28c95 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 10:55:11 +0800 Subject: [PATCH 061/109] cleanup(llm-pi-ai): share the model-entry field schemas between models and modelOverrides The duplication gate caught the two schema literals diverging only by the id field; the shared dict is now the single home, with the id added where it lives (the entry) and omitted where the dict key carries it. --- packages/llm/llm-pi-ai/src/config.ts | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index 7bce3b6376..e52af4a3f4 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -175,8 +175,8 @@ const reasoningEfforts = z.dict( z.union(THINKING_LEVELS), ) as unknown as z -const modelProfile: z = z.object({ - id: z.string().required(), +/** The fields a `models` entry and a `modelOverrides` value share; only the id's home differs. */ +const modelFields = { name: z.string(), contextWindow: z.number().step(1).min(1), maxTokens: z.number().step(1).min(1), @@ -185,16 +185,15 @@ const modelProfile: z = z.object({ // installed catalog's capability", while `false` disables reasoning. reasoningEfforts: z.union([z.const(false), reasoningEfforts]), compat: compatProfile, +} + +const modelProfile: z = z.object({ + id: z.string().required(), + ...modelFields, }) /** A {@link modelProfile} whose id lives in the `modelOverrides` dict key. */ -const modelOverride: z = z.object({ - name: z.string(), - contextWindow: z.number().step(1).min(1), - maxTokens: z.number().step(1).min(1), - reasoningEfforts: z.union([z.const(false), reasoningEfforts]), - compat: compatProfile, -}) +const modelOverride: z = z.object(modelFields) const profile = z.object({ apiKeyEnv: z.string().role('credential-ref'), From c480796db4d8ca94f8766f268d09ddf02fc93df3 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 11:06:29 +0800 Subject: [PATCH 062/109] docs(llm-pi-ai): state the composition-base assumption for the dict-merge limitation Maintainer ruling on the review's merge-semantics warning: per-model reasoning fields belong to the settings document, not cordis.yml entry config (the shipped composition mounts the adapter dormant), so the recursive-merge delete gap is a documented posture rather than a tracked fix; the Known Limitations entry now states the assumption instead of pointing at the closed #2003. --- packages/llm/llm-pi-ai/README.i18n.yaml | 4 ++-- packages/llm/llm-pi-ai/README.md | 2 +- packages/llm/llm-pi-ai/README.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 1fe2902388..790989c5d6 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: c5ebca23ccb4162b65a6e18132970eaf01a50b84 -README.zh.md: f916462bca915bea37c59f7a33a08e1dcc18c4c7 +README.md: eb67ce889193aadbd694d7aae53e47c7d20703be +README.zh.md: b4b3e3c208702fa10e5f434a70608702d0576fbd diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index c5ebca23cc..eb67ce8891 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -186,7 +186,7 @@ Recorded response content appends to the next request and does not invalidate it ## Known Limitations and Deferred Work - **Settings can add or override routes, not remove composition routes** — the user layer merges over the composition `base`, so deleting a `cordis.yml`-provided provider is a composition change; `replace` on the namespace only resets the user layer. -- **The layered merge has no delete for dict keys** — the settings seam merges the composition `base` and the user layer per key, recursively, so a `reasoningEfforts` level, `modelOverrides` entry, or `compat` field the base declares cannot be removed by the user layer, only overridden — and for `reasoningEfforts` absence *is* the meaning ("not offered"), so a base-declared level stays offered. A `models` list is an array and replaces wholesale, which is the workaround: declare the model there instead. Atomic-leaf merge semantics at the settings seam are tracked in [#2003](https://github.com/deepseek-harness/deepseek-harness/issues/2003). +- **The layered merge has no delete for dict keys** — the settings seam merges the composition `base` and the user layer per key, recursively, so a `reasoningEfforts` level, `modelOverrides` entry, or `compat` field the base declares cannot be removed by the user layer, only overridden — and for `reasoningEfforts` absence *is* the meaning ("not offered"), so a base-declared level stays offered. This only triggers when a `cordis.yml` entry config declares per-model reasoning fields for the same model the user layer edits; the supported posture is to leave those to the settings document (the shipped composition mounts the adapter dormant), and a `models` list is an array replacing wholesale, which is the in-band escape. - **`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. diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index f916462bca..b4b3e3c208 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -186,7 +186,7 @@ pi-ai 事件会变为 harness 推理、文本、工具调用、usage 与 finish ## 已知限制与暂缓事项 - **settings 能新增或覆盖路由,但不能移除组合路由**:用户层合并在组合 `base` 之上,因此删除 `cordis.yml` 提供的提供方属于组合变更;对该 namespace 执行 `replace` 只会重置用户层。 -- **分层合并对字典键没有删除语义**:settings seam 把组合 `base` 与用户层按键递归合并,因此 base 声明的某个 `reasoningEfforts` 档位、`modelOverrides` 条目或 `compat` 字段,用户层只能覆盖、无法移除——而 `reasoningEfforts` 里缺席本身*就是*语义(「不提供」),于是 base 声明过的档位会一直被提供。`models` 列表是数组、整体替换,这也是规避写法:把该模型改到那里声明。settings seam 的原子叶合并语义在 [#2003](https://github.com/deepseek-harness/deepseek-harness/issues/2003) 跟进。 +- **分层合并对字典键没有删除语义**:settings seam 把组合 `base` 与用户层按键递归合并,因此 base 声明的某个 `reasoningEfforts` 档位、`modelOverrides` 条目或 `compat` 字段,用户层只能覆盖、无法移除——而 `reasoningEfforts` 里缺席本身*就是*语义(「不提供」),于是 base 声明过的档位会一直被提供。只有 `cordis.yml` entry config 为用户层正在编辑的同一模型声明了按模型推理字段才会触发;受支持的姿态是把这些字段留给 settings 文档(shipped 组合以休眠方式挂载该适配器),且 `models` 列表是数组、整体替换,这是体制内的出口。 - **`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` 并把全部模型一起迁过去。把该提供方拆成两个路由键是变通办法。 From c4c2355b5047675e67b1921591f40eb066fa69a2 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 11:30:14 +0800 Subject: [PATCH 063/109] fix(host): harden skill.invoke at the enforcement boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fixes: recheck isUserInvocable on the loaded definition (list and get collect independently, so a provider change between them could swap in a user-disabled body — the skill-tool execute template's second check); thread the carrier signal through the lookup and refuse an abandoned caller's turn as cancelled; fold lookup/loader failures into the structured internal error the list face already uses; refuse cwd-less sessions with the skill.list stance; and reject blank trailing text at the wire schema instead of relying on client trimming. --- packages/host/apiproxy/src/api-proxy.ts | 60 +++++++--- .../host/apiproxy/src/api/skills.schema.ts | 7 +- packages/host/apiproxy/src/api/skills.ts | 10 +- packages/host/apiproxy/src/fetch/handler.ts | 2 +- .../apiproxy/tests/api-proxy-commands.spec.ts | 104 ++++++++++++++++-- .../host/apiproxy/tests/rpc-schemas.spec.ts | 2 + 6 files changed, 155 insertions(+), 30 deletions(-) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 3970a801a3..0abfb8c9c0 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -2390,32 +2390,58 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } }, - async invoke(request) { + async invoke(request, signal) { const { sessionId, name, text } = request.payload const resolved = await turnAgentFor<{ accepted: true }>(request, sessionId) if ('refused' in resolved) return resolved.refused const agent = resolved.agent + if (agent.session.header.cwd === undefined) { + // Same stance as skill.list: a cwd-less header is a pre-project + // legacy log, and skill discovery has no root to resolve against. + return err(request, { code: 'internal', message: `session "${sessionId}" has no project cwd`, details: {} }) + } const skillRegistry = ctx.get('skills') if (skillRegistry === undefined) { return err(request, { code: 'internal', message: 'skill registry is absent: this deployment does not mount @deepseek-ai/dsh-skill in its composition (cordis.yml or explicit assembly)', details: {} }) } - const lookup = { cwd: agent.session.header.cwd } - // isSkillName guards the registry contract; an ill-formed name is - // indistinguishable from an absent one for the caller. - const summary = isSkillName(name) - ? (await skillRegistry.list(lookup)).find(skill => skill.name === name) - : undefined - if (summary === undefined) { - return err(request, { code: 'skill-not-found', message: `skill "${name}" is unknown in this workspace`, details: { name } }) + const lookup = { cwd: agent.session.header.cwd, signal } + let skill + try { + // isSkillName guards the registry contract; an ill-formed name is + // indistinguishable from an absent one for the caller. + const summary = isSkillName(name) + ? (await skillRegistry.list(lookup)).find(candidate => candidate.name === name) + : undefined + if (summary === undefined) { + return err(request, { code: 'skill-not-found', message: `skill "${name}" is unknown in this workspace`, details: { name } }) + } + // The operation boundary owns user-invocation policy: client menus + // filtering their candidates is an affordance, not enforcement. + if (!isUserInvocable(summary)) { + return err(request, { code: 'skill-not-invocable', message: `skill "${name}" is not available for user invocation`, details: { name } }) + } + const loaded = await skillRegistry.get(name, lookup) + if (loaded === undefined) { + return err(request, { code: 'skill-not-found', message: `skill "${name}" is unknown in this workspace`, details: { name } }) + } + // Recheck on the loaded definition (the skill-tool execute template): + // list and get collect independently, so a provider change between + // the two awaits can swap the winning candidate for a user-disabled + // one — the boundary must judge what it actually injects. + if (!isUserInvocable(loaded)) { + return err(request, { code: 'skill-not-invocable', message: `skill "${name}" is not available for user invocation`, details: { name } }) + } + skill = loaded + } catch (error: unknown) { + if (signal.aborted) { + return err(request, { code: 'cancelled', message: 'skill invocation cancelled', details: {} }) + } + return err(request, { code: 'internal', message: `skill invocation failed: ${String(error)}`, details: {} }) } - // The operation boundary owns user-invocation policy: client menus - // filtering their candidates is an affordance, not enforcement. - if (!isUserInvocable(summary)) { - return err(request, { code: 'skill-not-invocable', message: `skill "${name}" is not available for user invocation`, details: { name } }) - } - const skill = await skillRegistry.get(name, lookup) - if (skill === undefined) { - return err(request, { code: 'skill-not-found', message: `skill "${name}" is unknown in this workspace`, details: { name } }) + if (signal.aborted) { + // The caller already gave up (unary deadline or navigation): a turn + // it will never observe must not start. + return err(request, { code: 'cancelled', message: 'skill invocation cancelled', details: {} }) } const body = renderSkillContent(skill) const source: SkillInvocationSource = { kind: 'skill-invocation', name, ...text === undefined ? {} : { args: text } } diff --git a/packages/host/apiproxy/src/api/skills.schema.ts b/packages/host/apiproxy/src/api/skills.schema.ts index c1ee1024a3..1741a93a46 100644 --- a/packages/host/apiproxy/src/api/skills.schema.ts +++ b/packages/host/apiproxy/src/api/skills.schema.ts @@ -27,11 +27,14 @@ export const skillListValueSchema = z.object({ skills: z.array(skillEntrySchema), }) satisfies z.ZodType>> -/** skill.invoke request payload. */ +/** + * skill.invoke request payload. `text` is the user's trailing message; a + * blank one stays off the wire (the boundary, not client courtesy, refuses it). + */ export const skillInvokeRequestSchema = z.object({ sessionId: sessionIdSchema, name: z.string().min(1), - text: z.string().optional(), + text: z.string().min(1).optional(), }) satisfies z.ZodType>> /** skill.invoke response value. */ diff --git a/packages/host/apiproxy/src/api/skills.ts b/packages/host/apiproxy/src/api/skills.ts index 2ade72efb9..698a9f0190 100644 --- a/packages/host/apiproxy/src/api/skills.ts +++ b/packages/host/apiproxy/src/api/skills.ts @@ -29,9 +29,13 @@ export interface SkillsApi { * Injects one user-invocable skill into the addressed agent as a user-role * message (the canonical `` rendering, with `text` appended * when present) and starts a turn. The host enforces user-invocation policy - * here: a model-only or unknown name is refused regardless of what a client - * menu offered. Session-backed subagents reject with `agent-busy`. + * here — on the discovery summary and again on the loaded definition, so a + * catalog change between the two lookups cannot slip a user-disabled body + * through — a model-only or unknown name is refused regardless of what a + * client menu offered. The carrier's request signal aborts the skill + * lookup and refuses injection once the caller has given up (`cancelled`). + * Session-backed subagents reject with `agent-busy`. */ - invoke(request: RpcRequest<{ sessionId: SessionId; name: string; text?: string }>): + invoke(request: RpcRequest<{ sessionId: SessionId; name: string; text?: string }>, signal: AbortSignal): Promise> } diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index 914c425e91..8e098680fa 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -109,7 +109,7 @@ const UNARY_ROUTES: UnaryRoutes = { 'command.list': { schema: commandListRequestSchema, invoke: (api, r) => api.commands.list(r) }, 'command.execute': { schema: commandExecuteRequestSchema, invoke: (api, r, signal) => api.commands.execute(r, signal) }, 'skill.list': { schema: skillListRequestSchema, invoke: (api, r) => api.skills.list(r) }, - 'skill.invoke': { schema: skillInvokeRequestSchema, invoke: (api, r) => api.skills.invoke(r) }, + 'skill.invoke': { schema: skillInvokeRequestSchema, invoke: (api, r, signal) => api.skills.invoke(r, signal) }, 'goal.create': { schema: goalCreateRequestSchema, invoke: (api, r) => api.goals.create(r) }, 'goal.edit': { schema: goalEditRequestSchema, invoke: (api, r) => api.goals.edit(r) }, 'goal.pause': { schema: goalPauseRequestSchema, invoke: (api, r) => api.goals.pause(r) }, diff --git a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts index 7d7062023e..5b61011370 100644 --- a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts @@ -305,6 +305,8 @@ describe('skill.invoke', () => { return { agent, followup } } + const live = () => new AbortController().signal + it('injects a user-invocable skill as a user message with the invocation source', async () => { const ctx = await harness() registerInvokeSkills(ctx) @@ -312,7 +314,7 @@ describe('skill.invoke', () => { const { agent, followup } = invokableAgent(ctx) const value = expectOk(await api.skills.invoke(request({ sessionId: agent.id, name: 'user-only', text: 'and check the fixture', - }))) + }), live())) expect(value).toEqual({ accepted: true }) expect(followup).toHaveBeenCalledTimes(1) const message = followup.mock.calls[0]?.[0] as UserMessage @@ -330,7 +332,7 @@ describe('skill.invoke', () => { registerInvokeSkills(ctx) const api = createApiProxy(ctx, DEFAULTS) const { agent, followup } = invokableAgent(ctx) - expectOk(await api.skills.invoke(request({ sessionId: agent.id, name: 'user-only' }))) + expectOk(await api.skills.invoke(request({ sessionId: agent.id, name: 'user-only' }), live())) const message = followup.mock.calls[0]?.[0] as UserMessage expect(message.source).toEqual({ kind: 'skill-invocation', name: 'user-only' }) const text = (message.content[0] as { text: string }).text @@ -342,39 +344,127 @@ describe('skill.invoke', () => { registerInvokeSkills(ctx) const api = createApiProxy(ctx, DEFAULTS) const { agent, followup } = invokableAgent(ctx) - const error = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'model-only' }))) + const error = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'model-only' }), live())) expect(error.code).toBe('skill-not-invocable') expect(followup).not.toHaveBeenCalled() }) + it('rechecks user policy on the loaded definition (list/get race)', async () => { + const ctx = await harness() + // The provider flips the skill user-invocable in list but user-disabled + // in get — the window a provider change between the two collects opens. + ctx.skills.registerProvider(() => ({ + name: 'flipping', + list: () => Promise.resolve([{ + name: 'flipper', description: 'Race probe', + invocation: { modelInvocable: false, userInvocable: true }, + source: 'custom', provider: 'flipping', rank: 0, locator: null, + }]), + get: () => Promise.resolve({ + name: 'flipper', description: 'Race probe', + invocation: { modelInvocable: false, userInvocable: false }, + source: 'custom', provider: 'flipping', + content: 'Must never inject.', + }), + })) + const api = createApiProxy(ctx, DEFAULTS) + const { agent, followup } = invokableAgent(ctx) + const error = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'flipper' }), live())) + expect(error.code).toBe('skill-not-invocable') + expect(followup).not.toHaveBeenCalled() + }) + + it('reports skill-not-found when the summary wins but the load returns nothing', async () => { + const ctx = await harness() + ctx.skills.registerProvider(() => ({ + name: 'vanishing', + list: () => Promise.resolve([{ + name: 'ghost', description: 'Vanishes on load', + invocation: { modelInvocable: false, userInvocable: true }, + source: 'custom', provider: 'vanishing', rank: 0, locator: null, + }]), + get: () => Promise.resolve(undefined), + })) + const api = createApiProxy(ctx, DEFAULTS) + const { agent, followup } = invokableAgent(ctx) + const error = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'ghost' }), live())) + expect(error.code).toBe('skill-not-found') + expect(followup).not.toHaveBeenCalled() + }) + it('rejects an unknown or invalid skill name', async () => { const ctx = await harness() registerInvokeSkills(ctx) const api = createApiProxy(ctx, DEFAULTS) const { agent } = invokableAgent(ctx) - const missing = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'absent-skill' }))) + const missing = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'absent-skill' }), live())) expect(missing.code).toBe('skill-not-found') - const invalid = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'Not A Name' }))) + const invalid = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'Not A Name' }), live())) expect(invalid.code).toBe('skill-not-found') }) + it('folds a loader failure into a structured internal error', async () => { + const ctx = await harness() + ctx.skills.registerProvider(() => ({ + name: 'exploding', + list: () => Promise.resolve([{ + name: 'grenade', description: 'Loader throws', + invocation: { modelInvocable: false, userInvocable: true }, + source: 'custom', provider: 'exploding', rank: 0, locator: null, + }]), + get: () => Promise.reject(new Error('disk exploded')), + })) + const api = createApiProxy(ctx, DEFAULTS) + const { agent, followup } = invokableAgent(ctx) + const error = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'grenade' }), live())) + expect(error.code).toBe('internal') + expect(error.message).toContain('skill invocation failed') + expect(followup).not.toHaveBeenCalled() + }) + + it('refuses to start a turn the caller already abandoned', async () => { + const ctx = await harness() + registerInvokeSkills(ctx) + const api = createApiProxy(ctx, DEFAULTS) + const { agent, followup } = invokableAgent(ctx) + const abort = new AbortController() + abort.abort() + const error = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'user-only' }), abort.signal)) + expect(error.code).toBe('cancelled') + expect(followup).not.toHaveBeenCalled() + }) + it('surfaces a followup refusal as agent-busy', async () => { const ctx = await harness() registerInvokeSkills(ctx) const api = createApiProxy(ctx, DEFAULTS) const { agent, followup } = invokableAgent(ctx) followup.mockImplementation(() => { throw new Error('inbox closed') }) - const error = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'user-only' }))) + const error = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'user-only' }), live())) expect(error.code).toBe('agent-busy') }) + it('refuses a cwd-less session with the skill.list stance', async () => { + const ctx = await harness() + registerInvokeSkills(ctx) + const api = createApiProxy(ctx, DEFAULTS) + const session = ctx.sessions.create(undefined) + const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) + const followup = vi.fn() + ctx.agents.register({ id: session.id, session, inbox, status: 'idle', ctx, followup } as unknown as Agent) + const error = expectErr(await api.skills.invoke(request({ sessionId: session.id, name: 'user-only' }), live())) + expect(error.code).toBe('internal') + expect(error.message).toContain('has no project cwd') + expect(followup).not.toHaveBeenCalled() + }) + it('fails loud with internal when the skill registry is not mounted', async () => { const ctx = await harness({ skills: false }) const api = createApiProxy(ctx, DEFAULTS) const session = ctx.sessions.create(undefined, { meta: { cwd: '/proj' } }) const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) ctx.agents.register({ id: session.id, session, inbox, status: 'idle', ctx, followup: vi.fn() } as unknown as Agent) - const error = expectErr(await api.skills.invoke(request({ sessionId: session.id, name: 'user-only' }))) + const error = expectErr(await api.skills.invoke(request({ sessionId: session.id, name: 'user-only' }), live())) expect(error.code).toBe('internal') expect(error.message).toContain('skill registry is absent') }) diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 253ac92fdf..972ccd3621 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -416,6 +416,8 @@ describe('skills domain schemas', () => { .toBe('check it') expect(() => skillInvokeRequestSchema.parse({ sessionId: 's1', name: '' })).toThrow() expect(() => skillInvokeRequestSchema.parse({ name: 'user-only' })).toThrow() + // A blank trailing text is refused at the wire boundary, not by client courtesy. + expect(() => skillInvokeRequestSchema.parse({ sessionId: 's1', name: 'user-only', text: '' })).toThrow() expect(skillInvokeValueSchema.parse({ accepted: true })).toEqual({ accepted: true }) expect(() => skillInvokeValueSchema.parse({ accepted: false })).toThrow() }) From 31ed85900d0707b309e3859484a5b3f4964721fb Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 11:30:16 +0800 Subject: [PATCH 064/109] fix(client): review fixes for invocation rendering and turn boundaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The user-turn predicate (opensUserTurn) unifies the three parallel consumers a new node kind silently missed — produced-files turn reset, retry liveness, own-words force-scroll — so a skill invocation behaves as the turn opener it is. The menu marker resolves through ctx.locale.bind instead of a hand-rolled snapshot lookup; the dead legacy render arm goes with the removal cut; command-over-skill name precedence is now documented at the matchEnter seam; and the emptied replacement catalog keeps the no-reload sentence, with the never-published residual recorded in the Agent Note. --- ...8-user-explicit-skill-invocation.i18n.yaml | 4 ++-- ...26-08-08-user-explicit-skill-invocation.md | 1 + ...08-08-user-explicit-skill-invocation.zh.md | 1 + .../client/connection/src/client/fixture.ts | 2 +- packages/client/runtime/src/client/index.ts | 1 + .../src/client/sessions/conversation.ts | 14 +++++++++++++ .../src/client/chat/ChatView.tsx | 10 +++++---- .../src/client/chat/MessageItem.tsx | 20 ++++++++---------- .../src/client/turn-deliverables.ts | 3 ++- .../tests/produced-files.spec.tsx | 21 +++++++++++++++++++ packages/client/ui-skill/README.i18n.yaml | 4 ++-- packages/client/ui-skill/README.md | 2 +- packages/client/ui-skill/README.zh.md | 2 +- packages/client/ui-skill/src/client/index.ts | 11 +++++++--- .../ui-skill/tests/browser-plugin.spec.ts | 3 ++- packages/skill/tool-skill/README.i18n.yaml | 4 ++-- packages/skill/tool-skill/README.md | 2 +- packages/skill/tool-skill/README.zh.md | 2 +- packages/skill/tool-skill/src/index.ts | 1 + 19 files changed, 77 insertions(+), 31 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.i18n.yaml index ed9de78dbb..4c36032f35 100644 --- a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.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-08-08-user-explicit-skill-invocation.md -2026-08-08-user-explicit-skill-invocation.md: 9249ee5c9c712e9c6aa827e97178f352728ed927 -2026-08-08-user-explicit-skill-invocation.zh.md: f15975c3b13fbf76e036fcece30253e78e7b417d +2026-08-08-user-explicit-skill-invocation.md: abe6a05283359b81ff1c3cab754d0230e599e4a0 +2026-08-08-user-explicit-skill-invocation.zh.md: e72e49236ffd2c6f664e01abbd69665eec8328e9 diff --git a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md index 9249ee5c9c..abe6a05283 100644 --- a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md +++ b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md @@ -34,3 +34,4 @@ Peer-product survey (Pi, OpenCode, Claude Code, Kimi Code, Codex, DeepSeek-Reaso - Every user-invocable skill invocation now costs its full rendered body unconditionally — the price of determinism the peer survey showed everyone pays. - The `skill-invocation` source rides `user/message`, so Model-visible ⟺ logged holds with no new event type, and replay/UI read metadata rather than text markers. - TUI and ACP can adopt `skill.invoke` later for the same semantics; until then the TUI's client-side expansion remains its own path. +- Accepted residual of dropping the per-injection preamble: the no-reload framing rides only the catalog, and a workspace whose skills are all user-only never publishes a first catalog — an injection can arrive with no framing at all, and the model may redundantly try the `skill` tool once (the replacement catalog's empty arm carries the sentence; the never-published case does not). Publishing a catalog for framing alone was judged worse than that one recoverable error. diff --git a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.zh.md b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.zh.md index f15975c3b1..e72e49236f 100644 --- a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.zh.md @@ -34,3 +34,4 @@ Status: implemented - 每一次用户可调用 skill 的调用现在都无条件付出其完整渲染正文的成本——这是确定性的代价,同类调研表明所有产品都在支付。 - `skill-invocation` 来源搭乘 `user/message`,因此「模型可见 ⟺ 已记录」在不新增事件类型的情况下继续成立,回放与 UI 读取的是元数据而非文本标记。 - TUI 与 ACP 之后可以为同样的语义采用 `skill.invoke`;在那之前,TUI 的客户端展开仍是它自己的路径。 +- 放弃逐次注入前导语后被接受的残余:no-reload framing 只搭乘目录,而 skill 全部为仅用户的工作区永远不会发布首个目录——注入可能在完全没有 framing 的情况下到达,模型可能多余地调用一次 `skill` 工具(替换目录的空臂携带该句;从未发布的情形没有)。仅为 framing 而发布目录被判定比这一次可恢复的错误更糟。 diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 75653d43e3..23b1931cfc 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -2779,7 +2779,7 @@ export class FixtureApiClient extends AbstractApiClient { case 'command.list': return this.api.commands.list(request) case 'command.execute': return this.api.commands.execute(request, signal) case 'skill.list': return this.api.skills.list(request) - case 'skill.invoke': return this.api.skills.invoke(request) + case 'skill.invoke': return this.api.skills.invoke(request, signal) case 'goal.create': return this.api.goals.create(request) case 'goal.edit': return this.api.goals.edit(request) case 'goal.pause': return this.api.goals.pause(request) diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index a0aa4df482..3864338e28 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -45,6 +45,7 @@ export { createSnapshotStore, defineStore, shallowEqual } from './contract/store export type { EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore, } from './contract/store.ts' +export { opensUserTurn } from './sessions/conversation.ts' export type { AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig, AssistantTiming, CodeSubCall, CommandNode, CompactionSummaryNode, ComposerPhase, diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index d66faf5e95..1ced1b916e 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -258,6 +258,20 @@ export interface CommandNode { outcome: { kind: 'success' | 'error'; text?: string } | null } +/** + * Whether a node opens a user turn on the transcript surface. A direct user + * message and a user-explicit skill invocation both start the turn the next + * assistant answer closes; parallel consumers (turn boundaries, retry + * liveness, own-words scrolling) share this one predicate instead of each + * re-encoding the kind list. Steering stays out: an interjection lands + * mid-turn and closes nothing. + * @param node - any conversation node. + * @returns true for the user-turn-opening kinds. + */ +export function opensUserTurn(node: Pick): boolean { + return node.kind === 'user' || node.kind === 'skill-invocation' +} + /** Finalized conversation node union (kind discriminates; seq is the React key). */ export type ConversationNode = | UserMessageNode diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index b0907f5a80..a841ba6751 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -24,6 +24,7 @@ import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode, } from 'react' +import { opensUserTurn } from '@deepseek-ai/dsh-client-runtime/client' import type { CodeSubCall, CommandNode, ConversationNode, ConversationSnapshot, RunningToolCall, ToolResultNode, } from '@deepseek-ai/dsh-client-runtime/client' @@ -118,7 +119,7 @@ function activeRetrySeq(nodes: readonly ConversationNode[], running: boolean): n const node = nodes[index] if (node === undefined) continue if (node.kind === 'model-retry') return node.retryState === 'cancelled' ? null : node.seq - if (node.kind === 'assistant' || node.kind === 'user') return null + if (node.kind === 'assistant' || opensUserTurn(node)) return null } return null } @@ -447,10 +448,11 @@ export function ChatView({ return } firstSeqRef.current = firstSeq - // Own words must be visible: a new trailing user node force-scrolls - // (send lives in the composer, so arrival is detected here, not armed there). + // Own words must be visible: a new trailing user-turn node (a prompt or an + // explicit skill invocation) force-scrolls (send lives in the composer, so + // arrival is detected here, not armed there). const appendedUser = lastKey !== lastKeyRef.current - && lastItem !== undefined && lastItem.kind === 'node' && lastItem.node.kind === 'user' + && lastItem !== undefined && lastItem.kind === 'node' && opensUserTurn(lastItem.node) const appendedSteering = lastSteeringId !== null && lastSteeringId !== lastSteeringIdRef.current const tipMoved = followSigRef.current !== followSig lastKeyRef.current = lastKey diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index 661dd0cda5..af2afd9792 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -138,29 +138,27 @@ function TurnErrorItem({ node, t }: { /** * Display projection of reference forms in a user bubble (free geometry — no * textarea alignment constraint here); everything else stays plain text. The - * logged model text remains the single truth; this is presentation only. Two - * shapes decorate: legacy `name` spans (pre-decision-21 - * history) and plain-text `/name` / `@name` word-boundary tokens (decision - * 21: the sent text IS the reference — the bubble uses the same plainest - * token scan as the composer, minus the lexicon: sent tokens were validated - * at compose time, so shape alone decorates). + * logged model text remains the single truth; this is presentation only. + * Plain-text `/name` / `@name` word-boundary tokens decorate (decision 21: + * the sent text IS the reference — the bubble uses the same plainest token + * scan as the composer, minus the lexicon: sent tokens were validated at + * compose time, so shape alone decorates). */ function projectUserText(text: string): ReactNode { - const re = /([^<]+)<\/skill>|(^|\s)([/@][\w-]+)(?=\s|$)/g + const re = /(^|\s)([/@][\w-]+)(?=\s|$)/g const parts: ReactNode[] = [] let cursor = 0 let m: RegExpExecArray | null while ((m = re.exec(text)) !== null) { - const legacy = m[1] !== undefined - const tokenStart = legacy ? m.index : m.index + (m[2]?.length ?? 0) - const label = legacy ? `/${m[1]}` : m[3] ?? '' + const tokenStart = m.index + (m[1]?.length ?? 0) + const label = m[2] ?? '' if (tokenStart > cursor) parts.push() parts.push( {label} , ) - cursor = legacy ? m.index + m[0].length : tokenStart + label.length + cursor = tokenStart + label.length } if (parts.length === 0) return if (cursor < text.length) parts.push() diff --git a/packages/client/ui-deliverables/src/client/turn-deliverables.ts b/packages/client/ui-deliverables/src/client/turn-deliverables.ts index c9754d1da4..b8886be0df 100644 --- a/packages/client/ui-deliverables/src/client/turn-deliverables.ts +++ b/packages/client/ui-deliverables/src/client/turn-deliverables.ts @@ -3,6 +3,7 @@ * nodes. Client-only and model-free: the vocabulary is the mutation tools' * own follow-along `locations`, never the closing prose. */ +import { opensUserTurn } from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationNode, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -62,7 +63,7 @@ export function producedForClosing(nodes: readonly ConversationNode[], seq: numb } continue } - if (node.kind === 'user') { + if (opensUserTurn(node)) { turn = undefined pending = [] seen = new Set() diff --git a/packages/client/ui-deliverables/tests/produced-files.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.spec.tsx index 49e41ebd86..473defc4e6 100644 --- a/packages/client/ui-deliverables/tests/produced-files.spec.tsx +++ b/packages/client/ui-deliverables/tests/produced-files.spec.tsx @@ -73,6 +73,27 @@ describe('producedForClosing derivation', () => { expect(producedForClosing(nodes, 999)).toEqual([]) }) + it('treats a user-explicit skill invocation as a turn boundary', () => { + // The injection opens a user turn exactly like a typed prompt: files + // written before it must not spill into the turn its answer closes. + const skillInvocation = { + kind: 'skill-invocation' as const, seq: 4, time: 4_000, + name: 'hidden-demo', + content: [{ type: 'text', text: 'x' }] as never, + source: null, + } + const nodes: ConversationNode[] = [ + user(1, 'write things'), + assistant(2, 'wrote', 1), + wrote(3, 'a', 'stale.txt'), + skillInvocation, + wrote(5, 'b', 'fresh.txt'), + assistant(6, 'followed the skill', 2), + ] + expect(producedForClosing(nodes, 6)).toEqual(['fresh.txt']) + expect(producedForClosing(nodes, 6)).not.toContain('stale.txt') + }) + it('counts a generic edit and never spills across the turn boundary', () => { const inserted = (seq: number, callId: string, path: string): ToolResultNode => ({ ...toolResult(seq, callId, 'str_replace_editor'), diff --git a/packages/client/ui-skill/README.i18n.yaml b/packages/client/ui-skill/README.i18n.yaml index cb9eeef56e..5b80baa912 100644 --- a/packages/client/ui-skill/README.i18n.yaml +++ b/packages/client/ui-skill/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-skill/README.md -README.md: c888622bc92b038413c7d0ebf63abb61b483f6f5 -README.zh.md: 3bbbc90186726356c53f375bb664d678c4926988 +README.md: ea3dbf3592995903422ec951e20c911082370dbe +README.zh.md: 5b8886e67973af9a594ff6aa2e9295f112a9f3e3 diff --git a/packages/client/ui-skill/README.md b/packages/client/ui-skill/README.md index c888622bc9..ea3dbf3592 100644 --- a/packages/client/ui-skill/README.md +++ b/packages/client/ui-skill/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Skill invocation source, browser half: registers the `/`-trigger `skill` source into `ctx.slash`. Ordinary-session candidates come from the `skill.list` RPC addressed by the per-call `ClientSessionContext` projection's `{sessionId}`, with the host resolving `cwd` from the session header. The host serves every user-invocable skill; a `modelInvocable: false` entry (a `disable-model-invocation` skill, whose only entry point is this path) wears the user-only marker as a description prefix in the active language. Catalog-addressed continuable children resolve no skill candidates locally because the existing skill RPC requires an attached session; viewing their persisted history must not activate them. Catalogs cache per ordinary session with a single-flight fetch; the scope-birth `warm` hook prewarms the session's entry and `connection/reset` clears everything. Results filter by `startsWith(query)`. -A menu pick or an entered `/name [args]` line claims the composer into an args-tolerant `skill.invoke` transaction (`matchEnter` strong-waits the catalog; an unknown name answers undefined and stays a plain prompt). Submit trims the args, keeps blank args off the wire, and folds an RPC refusal into the composer's error outcome; the host renders the skill body and injects it as a user message before starting the turn, so invocation is deterministic for every user-invocable skill. The RPC rides the plugin's root-context connection captured at registration — the source never reads services off a per-call argument. Draft chip visuals still derive from the `lexicon` scan; the legacy `name` reference codec is gone (decision 21 removal cut) and `matchSpace` stays unimplemented — menu and enter own the skill flows. +A menu pick or an entered `/name [args]` line claims the composer into an args-tolerant `skill.invoke` transaction (`matchEnter` strong-waits the catalog; an unknown name answers undefined and stays a plain prompt). A skill name shared with a host command resolves to the command: adjudication polls sources in registration order and the web bundle mounts ui-command ahead of this source — deliberate precedence, matching peer products. Submit trims the args, keeps blank args off the wire, and folds an RPC refusal into the composer's error outcome; the host renders the skill body and injects it as a user message before starting the turn, so invocation is deterministic for every user-invocable skill. The RPC rides the plugin's root-context connection captured at registration — the source never reads services off a per-call argument. Draft chip visuals still derive from the `lexicon` scan; the legacy `name` reference codec is gone (decision 21 removal cut) and `matchSpace` stays unimplemented — menu and enter own the skill flows. A failed `skill.list` throws from `candidates`, which the slash shell logs and folds into a silent menu-group drop — the menu shows only pending/ready states. diff --git a/packages/client/ui-skill/README.zh.md b/packages/client/ui-skill/README.zh.md index 3bbbc90186..5b8886e679 100644 --- a/packages/client/ui-skill/README.zh.md +++ b/packages/client/ui-skill/README.zh.md @@ -4,7 +4,7 @@ skill(技能)调用 source 的浏览器端:把 `/` 触发的 `skill` source 注册进 `ctx.slash`。普通会话的候选来自 `skill.list` RPC,以每次调用的 `ClientSessionContext` 投影中的 `{sessionId}` 寻址,host 从会话 header 解析 `cwd`。宿主提供每一个用户可调用的 skill;`modelInvocable: false` 的条目(即 `disable-model-invocation` skill,此路径是其唯一入口)会以当前语言把仅限用户标记作为描述前缀带上。由目录寻址的可继续 subagent 在客户端解析为没有 skill 候选,因为现有 skill RPC 要求会话已挂载;查看其持久化历史不得激活它。目录按普通会话缓存,拉取走 single-flight;scope 创建时的 `warm` 钩子预热该会话的缓存项,`connection/reset` 清空全部缓存。结果按 `startsWith(query)` 过滤。 -菜单 pick 或回车提交的一行 `/name [args]` 会把 composer 认领进一个容忍参数的 `skill.invoke` 事务(`matchEnter` 强等目录;未知名称应答 undefined,保持为普通提示词)。提交时会修剪参数、让空白参数不上协议,并把 RPC 拒绝折叠进 composer 的错误结局;宿主在开启轮次之前渲染 skill 正文并将其作为用户消息注入,因此对每一个用户可调用的 skill,调用都是确定性的。RPC 使用插件注册时捕获的根上下文连接——source 绝不从每次调用的参数上读取服务。草稿 chip 视觉仍由 `lexicon` 扫描派生;旧的 `name` 引用 codec 已经移除(决策 21 的移除裁定),`matchSpace` 保持不实现——skill 流程归菜单与回车所有。 +菜单 pick 或回车提交的一行 `/name [args]` 会把 composer 认领进一个容忍参数的 `skill.invoke` 事务(`matchEnter` 强等目录;未知名称应答 undefined,保持为普通提示词)。与宿主命令同名的 skill 名解析为命令:裁决按注册顺序轮询各 source,而 web bundle 把 ui-command 挂载在本 source 之前——这是有意的优先级,与同行产品一致。提交时会修剪参数、让空白参数不上协议,并把 RPC 拒绝折叠进 composer 的错误结局;宿主在开启轮次之前渲染 skill 正文并将其作为用户消息注入,因此对每一个用户可调用的 skill,调用都是确定性的。RPC 使用插件注册时捕获的根上下文连接——source 绝不从每次调用的参数上读取服务。草稿 chip 视觉仍由 `lexicon` 扫描派生;旧的 `name` 引用 codec 已经移除(决策 21 的移除裁定),`matchSpace` 保持不实现——skill 流程归菜单与回车所有。 `skill.list` 失败时 `candidates` 抛出异常,slash 壳层记录日志并折叠为静默的菜单组丢弃——菜单只显示 pending/ready 状态。 diff --git a/packages/client/ui-skill/src/client/index.ts b/packages/client/ui-skill/src/client/index.ts index 3e23cc997b..a73370b8ff 100644 --- a/packages/client/ui-skill/src/client/index.ts +++ b/packages/client/ui-skill/src/client/index.ts @@ -121,8 +121,9 @@ export function apply(ctx: ClientContext): void { for (const key of [...fetches.keys()]) invalidate(key) } - /** User-only marker in the active language (the menu hint is plain text, resolved at candidate time). */ - const userOnlyHint = (): string => ctx.locale.getSnapshot().active === 'zh' ? zh['menu.userOnly'] : en['menu.userOnly'] + // The bound translate resolves against the registered dictionaries with the + // locale service's own fallback ladder; candidate-time reads stay plain text. + const t = ctx.locale.bind(NS) /** * Args-tolerant claim for one skill: token `/name ` plus the skill.invoke @@ -159,7 +160,7 @@ export function apply(ctx: ClientContext): void { name: skill.name, // The user-only marker rides the description (the menu's only // secondary text); `hint` is the claim-state ghost text, not a badge. - description: skill.modelInvocable ? skill.description : `${userOnlyHint()} · ${skill.description}`, + description: skill.modelInvocable ? skill.description : `${t('menu.userOnly')} · ${skill.description}`, })) }, warm(session) { @@ -183,6 +184,10 @@ export function apply(ctx: ClientContext): void { onPick({ candidate, session }) { return invokeClaim(session, candidate.name) }, + // Adjudication polls sources in registration order and the web bundle + // mounts ui-command first, so a name shared with a host command claims as + // the command — deliberate precedence (commands are explicit host + // features; peer products resolve the collision the same way), not a race. async matchEnter(session, line, signal) { const trimmed = line.trim() if (!trimmed.startsWith('/')) return undefined diff --git a/packages/client/ui-skill/tests/browser-plugin.spec.ts b/packages/client/ui-skill/tests/browser-plugin.spec.ts index 0e098a0b30..da99ed70d3 100644 --- a/packages/client/ui-skill/tests/browser-plugin.spec.ts +++ b/packages/client/ui-skill/tests/browser-plugin.spec.ts @@ -53,7 +53,8 @@ function providePresentation(ctx: Context): PresentationCapture { capture.dictionaries.push({ namespace, dictionaries }) return () => { capture.localeDisposed = true } }, - getSnapshot: () => ({ active: 'zh', locales: ['zh', 'en'], revision: 0 }), + // Minimal bound-translate fake: zh dictionary lookup, key passthrough on miss. + bind: () => (key: string) => key === 'menu.userOnly' ? '仅用户' : key, }) return capture } diff --git a/packages/skill/tool-skill/README.i18n.yaml b/packages/skill/tool-skill/README.i18n.yaml index 19fa44c67c..7094272679 100644 --- a/packages/skill/tool-skill/README.i18n.yaml +++ b/packages/skill/tool-skill/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/skill/tool-skill/README.md -README.md: 5c6e592c670f324eb660dbe1fec168fd77e5b368 -README.zh.md: 202a621b1d4047c7d763de3b98c1a69c8c1ee1f7 +README.md: 21c3521aeff8b55940b04e804d5b8469850ec6da +README.zh.md: 74137ce7e577a4b5c6d3592b60bac3c5901a9159 diff --git a/packages/skill/tool-skill/README.md b/packages/skill/tool-skill/README.md index 5c6e592c67..21c3521aef 100644 --- a/packages/skill/tool-skill/README.md +++ b/packages/skill/tool-skill/README.md @@ -36,7 +36,7 @@ Tool execution does not add a synthetic context message. Its freshly loaded resu #### What the model sees -If model-invocable skills exist and this exact `skill` tool is visible, the agent receives the catalog template below as a durable user-role message before the first request, with one data-dependent entry per sorted skill. Later membership, description, or visibility changes append a complete replacement using the same `` envelope; deleting every skill appends an empty envelope with an explicit instruction not to use older names. The template's closing sentence is the seam rule against double-loading: the host's user-explicit `skill.invoke` injects the same `renderSkillContent` output (shared from `@deepseek-ai/dsh-skill`) inline, and the catalog tells the model to follow that block instead of re-loading the skill through the tool; the replacement-catalog template carries the same sentence. +If model-invocable skills exist and this exact `skill` tool is visible, the agent receives the catalog template below as a durable user-role message before the first request, with one data-dependent entry per sorted skill. Later membership, description, or visibility changes append a complete replacement using the same `` envelope; deleting every skill appends an empty envelope with an explicit instruction not to use older names. The template's closing sentence is the seam rule against double-loading: the host's user-explicit `skill.invoke` injects the same `renderSkillContent` output (shared from `@deepseek-ai/dsh-skill`) inline, and the catalog tells the model to follow that block instead of re-loading the skill through the tool; the replacement-catalog template carries the same sentence in both arms, including the emptied catalog. ##### Skill catalog template diff --git a/packages/skill/tool-skill/README.zh.md b/packages/skill/tool-skill/README.zh.md index 202a621b1d..74137ce7e5 100644 --- a/packages/skill/tool-skill/README.zh.md +++ b/packages/skill/tool-skill/README.zh.md @@ -36,7 +36,7 @@ #### 模型看到的内容 -如果存在模型可调用 skill,且可见的正是这个 `skill` 工具,agent 会在第一个请求之前收到下方目录模板,其中包含每个已排序 skill 的一条随数据而定的条目。该目录是一条持久的用户角色消息。后续成员关系、描述或可见性的变化会使用同一个 `` 信封追加完整替换;删除所有 skill 时,会追加一个空信封,并明确指示不得使用旧名称。模板的结尾一句是防止双重加载的 seam 规则:宿主的用户显式 `skill.invoke` 会把同一份 `renderSkillContent` 输出(共享自 `@deepseek-ai/dsh-skill`)内联注入,目录则告诉模型遵循该块,而不是再经工具重新加载该 skill;替换目录模板携带同一句话。 +如果存在模型可调用 skill,且可见的正是这个 `skill` 工具,agent 会在第一个请求之前收到下方目录模板,其中包含每个已排序 skill 的一条随数据而定的条目。该目录是一条持久的用户角色消息。后续成员关系、描述或可见性的变化会使用同一个 `` 信封追加完整替换;删除所有 skill 时,会追加一个空信封,并明确指示不得使用旧名称。模板的结尾一句是防止双重加载的 seam 规则:宿主的用户显式 `skill.invoke` 会把同一份 `renderSkillContent` 输出(共享自 `@deepseek-ai/dsh-skill`)内联注入,目录则告诉模型遵循该块,而不是再经工具重新加载该 skill;替换目录模板的两个臂——包括清空后的目录——都携带同一句话。 ##### Skill 目录模板 diff --git a/packages/skill/tool-skill/src/index.ts b/packages/skill/tool-skill/src/index.ts index aa9b509206..1d3d26a7c9 100644 --- a/packages/skill/tool-skill/src/index.ts +++ b/packages/skill/tool-skill/src/index.ts @@ -233,6 +233,7 @@ function renderCatalogUpdate(entries: SkillCatalogSource['entries']): UserMessag const availability = entries.length === 0 ? [ 'No skills are currently available through the `skill` tool. Do not use names from earlier skill catalogs.', + 'A user may still invoke a skill directly; its block then appears in this conversation. Follow it, and do not call the `skill` tool for it.', ] : [ 'Use only names in this replacement catalog. If the user names a listed skill, or the task clearly matches its description, call the `skill` tool with the exact name before acting.', From 7750789c8e9e797718c447e7a9483727aae1b191 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 11:32:28 +0800 Subject: [PATCH 065/109] docs: regenerate the module graph for the dsh-skill llm dependency --- 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 9cf6f4c895..bbd0af9d8a 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -297,7 +297,6 @@ flowchart TD pkg_retention --> pkg_invariants 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 @@ -367,6 +366,8 @@ flowchart TD pkg_system_prompt --> pkg_invariants pkg_system_prompt --> pkg_llm pkg_system_prompt --> pkg_scope + pkg_skill --> pkg_invariants + pkg_skill --> pkg_llm pkg_web --> pkg_invariants pkg_web --> pkg_llm pkg_api_gateway --> pkg_client_connection @@ -1156,7 +1157,6 @@ flowchart TD | [`retention`](../packages/util/retention) | `util` | [`invariants`](../packages/support/invariants) | | [`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) | @@ -1194,6 +1194,7 @@ flowchart TD | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`type-meta`](../packages/typert/type-meta) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | +| [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`api-gateway`](../packages/api/gateway) | `api` | [`client-connection`](../packages/client/connection), [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | 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 066/109] 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 067/109] 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 068/109] 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 069/109] 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", From c08fa27e5ca3c5bfeb7e3e931a39b8e8249b1f27 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 13:14:49 +0800 Subject: [PATCH 070/109] feat(tool-skill): inject user-invoked skills at the pre-step gesture boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A whitespace-bounded /name token anywhere in a claimed user message, naming a user-invocable skill in the workspace directory, now injects that skill's renderSkillContent as instructions context appended after every other injection of the step — the same agent/pre-step seam the catalog, workspace instructions, and the runtime snapshot ride. Closed-set matching mirrors the command registry (a miss stays plain prose), only user-source messages are scanned, the policy check runs on the loaded definition, and this is the sole entry point for disable-model-invocation skills. The catalog's no-reload sentence now names the gesture boundary. --- packages/host/apiproxy/src/api-proxy.ts | 75 +------------ packages/skill/skill/README.i18n.yaml | 4 +- packages/skill/skill/README.md | 2 +- packages/skill/skill/README.zh.md | 2 +- packages/skill/skill/src/index.ts | 13 ++- packages/skill/tool-skill/README.i18n.yaml | 4 +- packages/skill/tool-skill/README.md | 16 ++- packages/skill/tool-skill/README.zh.md | 16 ++- packages/skill/tool-skill/src/index.ts | 76 +++++++++++++ .../skill/tool-skill/tests/tool-skill.spec.ts | 103 ++++++++++++++++++ 10 files changed, 225 insertions(+), 86 deletions(-) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 0abfb8c9c0..cfcae423bc 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -18,8 +18,7 @@ import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-session-query' import { SubagentError } from '@deepseek-ai/dsh-subagent' import type { SubagentListEntry as CatalogSubagentListEntry } from '@deepseek-ai/dsh-subagent' -import { isSkillName, isUserInvocable, renderSkillContent } from '@deepseek-ai/dsh-skill' -import type { SkillInvocationSource } from '@deepseek-ai/dsh-skill' +import { isUserInvocable } from '@deepseek-ai/dsh-skill' import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace' import { workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId, @@ -1254,9 +1253,8 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro * turn, and letting it try spends the whole pre-step path to fail inside * the adapter with a message about registration. Refusing here names the * model the session is pointed at while the draft is still in the composer. - * This is the enforcement boundary shared by `session.prompt` and - * `skill.invoke`: a client that disables its input is an affordance, and - * both methods stay callable regardless. + * This is `session.prompt`'s enforcement boundary: a client that disables + * its input is an affordance, and the method stays callable regardless. */ async function turnAgentFor( request: RpcRequest, sessionId: SessionId, @@ -2389,73 +2387,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro return err(request, { code: 'internal', message: `skill listing failed: ${String(error)}`, details: {} }) } }, - - async invoke(request, signal) { - const { sessionId, name, text } = request.payload - const resolved = await turnAgentFor<{ accepted: true }>(request, sessionId) - if ('refused' in resolved) return resolved.refused - const agent = resolved.agent - if (agent.session.header.cwd === undefined) { - // Same stance as skill.list: a cwd-less header is a pre-project - // legacy log, and skill discovery has no root to resolve against. - return err(request, { code: 'internal', message: `session "${sessionId}" has no project cwd`, details: {} }) - } - const skillRegistry = ctx.get('skills') - if (skillRegistry === undefined) { - return err(request, { code: 'internal', message: 'skill registry is absent: this deployment does not mount @deepseek-ai/dsh-skill in its composition (cordis.yml or explicit assembly)', details: {} }) - } - const lookup = { cwd: agent.session.header.cwd, signal } - let skill - try { - // isSkillName guards the registry contract; an ill-formed name is - // indistinguishable from an absent one for the caller. - const summary = isSkillName(name) - ? (await skillRegistry.list(lookup)).find(candidate => candidate.name === name) - : undefined - if (summary === undefined) { - return err(request, { code: 'skill-not-found', message: `skill "${name}" is unknown in this workspace`, details: { name } }) - } - // The operation boundary owns user-invocation policy: client menus - // filtering their candidates is an affordance, not enforcement. - if (!isUserInvocable(summary)) { - return err(request, { code: 'skill-not-invocable', message: `skill "${name}" is not available for user invocation`, details: { name } }) - } - const loaded = await skillRegistry.get(name, lookup) - if (loaded === undefined) { - return err(request, { code: 'skill-not-found', message: `skill "${name}" is unknown in this workspace`, details: { name } }) - } - // Recheck on the loaded definition (the skill-tool execute template): - // list and get collect independently, so a provider change between - // the two awaits can swap the winning candidate for a user-disabled - // one — the boundary must judge what it actually injects. - if (!isUserInvocable(loaded)) { - return err(request, { code: 'skill-not-invocable', message: `skill "${name}" is not available for user invocation`, details: { name } }) - } - skill = loaded - } catch (error: unknown) { - if (signal.aborted) { - return err(request, { code: 'cancelled', message: 'skill invocation cancelled', details: {} }) - } - return err(request, { code: 'internal', message: `skill invocation failed: ${String(error)}`, details: {} }) - } - if (signal.aborted) { - // The caller already gave up (unary deadline or navigation): a turn - // it will never observe must not start. - return err(request, { code: 'cancelled', message: 'skill invocation cancelled', details: {} }) - } - const body = renderSkillContent(skill) - const source: SkillInvocationSource = { kind: 'skill-invocation', name, ...text === undefined ? {} : { args: text } } - try { - const message: UserMessage = createUserMessage({ - content: [{ type: 'text', text: text === undefined ? body : `${body}\n\n${text}` }], - source, - }) - agent.followup(message) - } catch (error: unknown) { - return err(request, { code: 'agent-busy', message: 'skill invocation rejected', details: { reason: String(error) } }) - } - return ok(request, { accepted: true as const }) - }, }, settings: { diff --git a/packages/skill/skill/README.i18n.yaml b/packages/skill/skill/README.i18n.yaml index fe29171cb3..2ca9cbac01 100644 --- a/packages/skill/skill/README.i18n.yaml +++ b/packages/skill/skill/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/skill/skill/README.md -README.md: 0c1b2249d8c46ad9ce8097ceeda2bd988c92eb21 -README.zh.md: 8fed350d00433206aecdb32819adc81c82745869 +README.md: 3dc2bcfa5775736717bdebcb92329d5655198234 +README.zh.md: d11f90d5a8356f06df63aa249a1f8b5851f36f5f diff --git a/packages/skill/skill/README.md b/packages/skill/skill/README.md index 0c1b2249d8..3dc2bcfa57 100644 --- a/packages/skill/skill/README.md +++ b/packages/skill/skill/README.md @@ -39,7 +39,7 @@ This package owns the `ctx.skills` interface. It does not know whether skills co ### Shared model-facing rendering -`renderSkillContent(skill)` renders one loaded skill as the canonical `` block (escaped `name` attribute, resource hints, verbatim body). It is the single truth for both loading paths: `dsh-tool-skill` returns it as the `skill` tool result, and the host's user-explicit `skill.invoke` injects it as a user message, so the model sees one shape regardless of who initiated the load. `escapeText` is exported beside it for consumers embedding prose in the same markup frame. The package also declares the `skill-invocation` `MessageSource` kind ({ name, args? }) that user-explicit injection stamps on its messages — transcript consumers present the invocation from this metadata instead of re-parsing the body. +`renderSkillContent(skill)` renders one loaded skill as the canonical `` block (escaped `name` attribute, resource hints, verbatim body). It is the single truth for both loading paths: `dsh-tool-skill` returns it as the `skill` tool result and injects it at the user-explicit gesture boundary, so the model sees one shape regardless of who initiated the load. `escapeText` is exported beside it for consumers embedding prose in the same markup frame. The package also declares the `skill-invocation` `MessageSource` kind ({ name, form: 'instructions' }) that user-explicit injection stamps on its messages — transcript consumers present the invocation from this metadata instead of re-parsing the body. `isModelInvocable(skill)` and `isUserInvocable(skill)` read the matching positive field directly. `ctx.skills.get()` remains the trusted, policy-neutral loading primitive, so every user- or model-facing consumer must enforce the predicate that matches its surface before exposing or loading a skill. diff --git a/packages/skill/skill/README.zh.md b/packages/skill/skill/README.zh.md index 8fed350d00..d11f90d5a8 100644 --- a/packages/skill/skill/README.zh.md +++ b/packages/skill/skill/README.zh.md @@ -39,7 +39,7 @@ ### 共享的面向模型渲染 -`renderSkillContent(skill)` 把一个已加载 skill 渲染为规范的 `` 块(转义后的 `name` 属性、资源提示、原样正文)。它是两条加载路径的唯一真源:`dsh-tool-skill` 将其作为 `skill` 工具结果返回,宿主的用户显式 `skill.invoke` 将其作为用户消息注入,因此无论加载由谁发起,模型看到的都是同一种形态。`escapeText` 随之一并导出,供要在同一标记框架中嵌入文案的消费方使用。该包还声明 `skill-invocation` 这个 `MessageSource` kind({ name, args? }),用户显式注入会把它打在自己的消息上——transcript(文本记录)消费方依据这份元数据呈现该次调用,而不是重新解析正文。 +`renderSkillContent(skill)` 把一个已加载 skill 渲染为规范的 `` 块(转义后的 `name` 属性、资源提示、原样正文)。它是两条加载路径的唯一真源:`dsh-tool-skill` 将其作为 `skill` 工具结果返回,并在用户显式的手势边界将其注入,因此无论加载由谁发起,模型看到的都是同一种形态。`escapeText` 随之一并导出,供要在同一标记框架中嵌入文案的消费方使用。该包还声明 `skill-invocation` 这个 `MessageSource` kind({ name, form: 'instructions' }),用户显式注入会把它打在自己的消息上——transcript(文本记录)消费方依据这份元数据呈现该次调用,而不是重新解析正文。 `isModelInvocable(skill)` 和 `isUserInvocable(skill)` 分别直接读取对应的正向字段。`ctx.skills.get()` 仍是受信且与策略无关的加载原语,因此每个面向用户或模型的消费方都必须先执行与自身接口匹配的判定,再暴露或加载 skill。 diff --git a/packages/skill/skill/src/index.ts b/packages/skill/skill/src/index.ts index f44386d51c..42478279b4 100644 --- a/packages/skill/skill/src/index.ts +++ b/packages/skill/skill/src/index.ts @@ -121,17 +121,18 @@ export function isUserInvocable(skill: Pick): boolea } /** - * Durable message source for a user-explicit skill invocation: the host - * injects the rendered skill as a user-role message carrying this source, so - * transcript consumers present the invocation from metadata instead of - * re-parsing the model-facing text. + * Durable source for the context message a user-explicit skill invocation + * injects: the user's own words ride a plain user message, and the rendered + * skill body follows as injected `instructions`-form context carrying this + * source, so transcript consumers present the injection from metadata + * instead of re-parsing the model-facing text. */ export interface SkillInvocationSource { readonly kind: 'skill-invocation' /** Invoked skill name, validated user-invocable at the injecting boundary. */ readonly name: string - /** Trailing free text the user submitted after the skill token, when present. */ - readonly args?: string + /** Injected skill bodies are instructions for the model to follow. */ + readonly form: 'instructions' } declare module '@deepseek-ai/dsh-llm' { diff --git a/packages/skill/tool-skill/README.i18n.yaml b/packages/skill/tool-skill/README.i18n.yaml index 7094272679..b9aa148fd1 100644 --- a/packages/skill/tool-skill/README.i18n.yaml +++ b/packages/skill/tool-skill/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/skill/tool-skill/README.md -README.md: 21c3521aeff8b55940b04e804d5b8469850ec6da -README.zh.md: 74137ce7e577a4b5c6d3592b60bac3c5901a9159 +README.md: b7309657d85a3d2a19de78a4ee6173d742519daa +README.zh.md: f430f4027c917c5c9b97a56d1a7d7a617670b25c diff --git a/packages/skill/tool-skill/README.md b/packages/skill/tool-skill/README.md index 21c3521aef..b7309657d8 100644 --- a/packages/skill/tool-skill/README.md +++ b/packages/skill/tool-skill/README.md @@ -36,7 +36,7 @@ Tool execution does not add a synthetic context message. Its freshly loaded resu #### What the model sees -If model-invocable skills exist and this exact `skill` tool is visible, the agent receives the catalog template below as a durable user-role message before the first request, with one data-dependent entry per sorted skill. Later membership, description, or visibility changes append a complete replacement using the same `` envelope; deleting every skill appends an empty envelope with an explicit instruction not to use older names. The template's closing sentence is the seam rule against double-loading: the host's user-explicit `skill.invoke` injects the same `renderSkillContent` output (shared from `@deepseek-ai/dsh-skill`) inline, and the catalog tells the model to follow that block instead of re-loading the skill through the tool; the replacement-catalog template carries the same sentence in both arms, including the emptied catalog. +If model-invocable skills exist and this exact `skill` tool is visible, the agent receives the catalog template below as a durable user-role message before the first request, with one data-dependent entry per sorted skill. Later membership, description, or visibility changes append a complete replacement using the same `` envelope; deleting every skill appends an empty envelope with an explicit instruction not to use older names. The template's closing sentence is the seam rule against double-loading: the user-explicit gesture boundary (the pre-step listener below) injects the same `renderSkillContent` output (shared from `@deepseek-ai/dsh-skill`) inline, and the catalog tells the model to follow that block instead of re-loading the skill through the tool; the replacement-catalog template carries the same sentence in both arms, including the emptied catalog. ##### Skill catalog template @@ -145,6 +145,20 @@ Only a failing call adds these retained tokens. Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. +### User-explicit invocation injection + +#### What the model sees + +A whitespace-bounded `/name` token anywhere in a claimed user message, naming a user-invocable skill in the workspace catalog, injects that skill's full `` rendering (the exact result-template shape above) as a `user`-role instructions context appended after every other injection of that step — background first, the material to act on last. Only direct user input is scanned, the check runs on the loaded definition, and unknown or user-disabled names stay ordinary prose. This is the sole entry point for `disable-model-invocation` skills, which the catalog and the `skill` tool never expose; the catalog's closing sentence tells the model to follow the injected block instead of re-loading it. + +#### Token effect + +Each gesture adds one rendered skill body to that turn as injected context — the same size as the tool result for the same skill, paid deterministically at the user's request instead of at the model's discretion. Repeated gestures for one skill within one step inject once. + +#### KV Cache effect + +Append-only; the injection lands after the reusable request prefix inside the step's message batch and does not invalidate existing KV-cache entries. + ## Known Limitations and Deferred Work - **The catalog omits `whenToUse`, source, and provider metadata** — routing is based only on name and a capped description; `whenToUse` remains provider metadata and is not rendered by the loaded wrapper either. diff --git a/packages/skill/tool-skill/README.zh.md b/packages/skill/tool-skill/README.zh.md index 74137ce7e5..f430f4027c 100644 --- a/packages/skill/tool-skill/README.zh.md +++ b/packages/skill/tool-skill/README.zh.md @@ -36,7 +36,7 @@ #### 模型看到的内容 -如果存在模型可调用 skill,且可见的正是这个 `skill` 工具,agent 会在第一个请求之前收到下方目录模板,其中包含每个已排序 skill 的一条随数据而定的条目。该目录是一条持久的用户角色消息。后续成员关系、描述或可见性的变化会使用同一个 `` 信封追加完整替换;删除所有 skill 时,会追加一个空信封,并明确指示不得使用旧名称。模板的结尾一句是防止双重加载的 seam 规则:宿主的用户显式 `skill.invoke` 会把同一份 `renderSkillContent` 输出(共享自 `@deepseek-ai/dsh-skill`)内联注入,目录则告诉模型遵循该块,而不是再经工具重新加载该 skill;替换目录模板的两个臂——包括清空后的目录——都携带同一句话。 +如果存在模型可调用 skill,且可见的正是这个 `skill` 工具,agent 会在第一个请求之前收到下方目录模板,其中包含每个已排序 skill 的一条随数据而定的条目。该目录是一条持久的用户角色消息。后续成员关系、描述或可见性的变化会使用同一个 `` 信封追加完整替换;删除所有 skill 时,会追加一个空信封,并明确指示不得使用旧名称。模板的结尾一句是防止双重加载的 seam 规则:用户显式的手势边界(下文的 pre-step 监听器)会把同一份 `renderSkillContent` 输出(共享自 `@deepseek-ai/dsh-skill`)内联注入,目录则告诉模型遵循该块,而不是再经工具重新加载该 skill;替换目录模板的两个臂——包括清空后的目录——都携带同一句话。 ##### Skill 目录模板 @@ -145,6 +145,20 @@ Load referenced resources only as needed. 仅追加;新可见内容位于可重用请求前缀之后,不会使现有 KV Cache 条目失效。 +### 用户显式调用注入 + +#### 模型看到的内容 + +已认领用户消息中任意位置、以空白为界、指名工作区目录中某个用户可调用 skill 的 `/name` token,会把该 skill 的完整 `` 渲染(与上文结果模板完全相同的形态)作为 `user` 角色的指令上下文注入,追加在该步骤所有其他注入之后——背景在前,模型要着手处理的材料在最后。只扫描直接的用户输入,检查在已加载定义上进行,未知名称和用户不可调用的名称保持为普通行文。这是 `disable-model-invocation` skill 唯一的入口,目录和 `skill` 工具永不暴露这类 skill;目录的结尾一句会告诉模型遵循注入块,而不是重新加载它。 + +#### Token 影响 + +每次手势会把一份渲染后的 skill 正文作为注入上下文加进该轮次——尺寸与同一 skill 的工具结果相同,按用户的请求确定性地支付,而非由模型自行裁量。同一步骤内对同一 skill 的重复手势只注入一次。 + +#### KV Cache 影响 + +仅追加;注入落在该步骤的消息批次中、可重用请求前缀之后,不会使现有 KV Cache 条目失效。 + ## 已知限制与暂缓事项 - **目录省略 `whenToUse`、来源和提供方元数据**:路由只基于名称和有长度上限的描述;`whenToUse` 仍是提供方元数据,加载后的包装层也不渲染它。 diff --git a/packages/skill/tool-skill/src/index.ts b/packages/skill/tool-skill/src/index.ts index 1d3d26a7c9..604cd63bcf 100644 --- a/packages/skill/tool-skill/src/index.ts +++ b/packages/skill/tool-skill/src/index.ts @@ -15,7 +15,9 @@ import { escapeText, isModelInvocable, isSkillName, + isUserInvocable, renderSkillContent, + type SkillInvocationSource, type SkillSummary, } from '@deepseek-ai/dsh-skill' @@ -161,6 +163,49 @@ export function apply(ctx: Context, config: Config = {}): void { throw new Error('dsh-tool-skill: registered skill tool is not visible in the global registry') } + // User-explicit skill invocation: a claimed user message whose first line + // starts with `/` naming a user-invocable skill is a deterministic + // load gesture. The rendered body enters this step as injected + // instructions context appended after every other injection — background + // first (workspace rules, runtime policy, the catalog), the material the + // model must act on last, closest to its answer. Registration order makes + // that placement deterministic: this listener registers before the catalog + // listener, so the waterfall hands it the catalog-bearing list to extend. + // Only `source.kind === 'user'` messages are scanned — external text + // cannot forge the gesture — and a token naming no user-invocable skill + // stays ordinary prose (the command registry is a different closed + // namespace, resolved client-side before a line ever becomes a prompt). + // This is the only entry point for `disable-model-invocation` skills; the + // catalog and the `skill` tool below never see them. + ctx.on('agent/pre-step', async ( + { agent, messages, signal }, + next, + ): Promise => { + const decision = await next() + if (decision.kind === 'reject') return decision + const names = invokedSkillNames(messages) + if (names.length === 0) return decision + signal.throwIfAborted() + const lookup = { cwd: agent.session.header.cwd, signal } + const injections: UserMessage[] = [] + for (const name of names) { + const skill = await ctx.skills.get(name, lookup) + signal.throwIfAborted() + // Unknown names and user-disabled skills stay plain prose: the + // gesture was never a claim this boundary recognizes. The check sits + // on the loaded definition — the single lookup that produces what is + // actually injected. + if (skill === undefined || !isUserInvocable(skill)) continue + const source: SkillInvocationSource = { kind: 'skill-invocation', name, form: 'instructions' } + injections.push(createUserMessage({ + content: [{ type: 'text', text: renderSkillContent(skill) }], + source, + })) + } + if (injections.length === 0) return decision + return { kind: 'enter', messages: [...decision.messages, ...injections] } + }) + // Register after the tool so reverse teardown removes guidance first. Exact definition // identity prevents a scoped shadow merely named `skill` from inheriting this catalog. ctx.on('agent/pre-step', async ( @@ -351,3 +396,34 @@ function assertPositiveInteger(name: string, value: number, minimum = 1): void { throw new Error(`tool-skill: ${name} must be an integer greater than or equal to ${minimum}`) } } + +/** + * A whitespace-bounded `/name` token (the public skill-name grammar) anywhere + * in the text — the same word-boundary shape the transcript chip decoration + * uses, so a gesture reads as one wherever it sits in the sentence. A second + * `/` or any non-boundary character breaks the match, which keeps file paths + * (`/usr/bin`) and fractions (`5/8`) out. + */ +const SKILL_GESTURE = /(^|\s)\/([a-z0-9]+(?:-[a-z0-9]+)*)(?=\s|$)/g + +/** + * `/name` gesture tokens from the claimed user messages, deduplicated in + * first-seen order. Every text block of direct user input is scanned; no + * other source can forge a gesture. + * @param messages - the step's claimed batch. + * @returns candidate skill names, unvalidated against the registry. + */ +function invokedSkillNames(messages: readonly UserMessage[]): string[] { + const names: string[] = [] + for (const message of messages) { + if ((message.source as { kind?: unknown }).kind !== 'user') continue + for (const block of message.content) { + if (block.type !== 'text') continue + for (const match of block.text.matchAll(SKILL_GESTURE)) { + const name = match[2] + if (name !== undefined && !names.includes(name)) names.push(name) + } + } + } + return names +} diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index 9543c196af..fe356da5da 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -915,3 +915,106 @@ describe('dsh-tool-skill', () => { expect(vanishedBlock.text).toContain('skill "vanishing-skill" is unknown or no longer available') }) }) + +describe('user-explicit invocation injection', () => { + async function writePolicySkill(root: string, name: string, description: string, policy: string, body: string): Promise { + const dir = join(root, name) + await mkdir(dir, { recursive: true }) + const policyLines = policy === '' ? '' : `${policy}\n` + await writeFile(join(dir, 'SKILL.md'), `---\nname: ${name}\ndescription: ${description}\n${policyLines}---\n\n${body}\n`) + } + + function gesture(text: string): UserMessage { + return createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } }) + } + + async function invokeHarness(): Promise<{ ctx: Context; agent: Agent }> { + const home = await tempDir('invoke') + const skillsRoot = join(home, '.agents', 'skills') + await writePolicySkill(skillsRoot, 'hidden-demo', 'User-only demo', 'disable-model-invocation: true', 'Say the magic word: PINEAPPLE.') + await writePolicySkill(skillsRoot, 'shared-skill', 'Ordinary skill', '', 'Shared instructions.') + await writePolicySkill(skillsRoot, 'model-only-skill', 'Model only', 'user-invocable: false', 'Model-only instructions.') + const ctx = await setup(home) + return { ctx, agent: agentForCwd(home) } + } + + it('injects a user-invocable skill named by a leading /token, after every other injection', async () => { + const { ctx, agent } = await invokeHarness() + const first = gesture('/hidden-demo what does this do') + const second = gesture('plain follow-up prose') + const decision = await proposeStep(ctx, agent, [first, second]) + if (decision.kind !== 'enter') throw new Error('expected enter') + const kinds = decision.messages.map(message => (message.source as { kind: string }).kind) + // Background injections (the catalog here) sit between the claimed batch + // and the invoked body: the material the model must act on comes last. + expect(kinds.slice(0, 2)).toEqual(['user', 'user']) + expect(kinds.at(-1)).toBe('skill-invocation') + expect(kinds.indexOf('skill-catalog')).toBeLessThan(kinds.indexOf('skill-invocation')) + const injection = decision.messages.at(-1)! + expect(injection.source).toMatchObject({ kind: 'skill-invocation', name: 'hidden-demo', form: 'instructions' }) + const block = injection.content[0] + if (block?.type !== 'text') throw new Error('expected text injection') + expect(block.text).toContain('') + expect(block.text).toContain('Say the magic word: PINEAPPLE.') + expect(block.text).not.toContain('what does this do') + }) + + it('injects an ordinary skill the same way (one uniform user-explicit path)', async () => { + const { ctx, agent } = await invokeHarness() + const decision = await proposeStep(ctx, agent, [gesture('/shared-skill go')]) + if (decision.kind !== 'enter') throw new Error('expected enter') + expect(decision.messages.some(message => + (message.source as { kind?: string; name?: string }).kind === 'skill-invocation' + && (message.source as { name?: string }).name === 'shared-skill')).toBe(true) + }) + + it('recognizes a mid-sentence gesture but not paths, fractions, or broken boundaries', async () => { + const { ctx, agent } = await invokeHarness() + const decision = await proposeStep(ctx, agent, [ + gesture('please use /hidden-demo to answer this'), + ]) + if (decision.kind !== 'enter') throw new Error('expected enter') + expect(decision.messages.some(message => + (message.source as { kind?: string; name?: string }).kind === 'skill-invocation' + && (message.source as { name?: string }).name === 'hidden-demo')).toBe(true) + + const negative = await proposeStep(ctx, agent, [ + gesture('look under /hidden-demo/refs for the data'), + gesture('the odds are 5/8 at best'), + gesture('see foo/hidden-demo too'), + ]) + if (negative.kind !== 'enter') throw new Error('expected enter') + expect(negative.messages.some(message => + (message.source as { kind?: string }).kind === 'skill-invocation')).toBe(false) + }) + + it('leaves unknown names and user-disabled skills as plain prose', async () => { + const { ctx, agent } = await invokeHarness() + const decision = await proposeStep(ctx, agent, [ + gesture('/absent-skill do a thing'), + gesture('/model-only-skill run'), + ]) + if (decision.kind !== 'enter') throw new Error('expected enter') + // No injection joins the step (the catalog listener may still add its + // own skill-catalog message; only skill-invocation sources matter here). + expect(decision.messages.some(message => + (message.source as { kind?: string }).kind === 'skill-invocation')).toBe(false) + }) + + it('never scans non-user sources and dedupes repeated gestures', async () => { + const { ctx, agent } = await invokeHarness() + const forged = createUserMessage({ + content: [{ type: 'text', text: '/hidden-demo forged' }], + source: { kind: 'skill-catalog', form: 'catalog', entries: [] }, + }) + const decision = await proposeStep(ctx, agent, [ + forged, + gesture('/hidden-demo once'), + gesture('/hidden-demo twice'), + ]) + if (decision.kind !== 'enter') throw new Error('expected enter') + const injections = decision.messages.filter(message => + (message.source as { kind?: string }).kind === 'skill-invocation') + expect(injections).toHaveLength(1) + }) +}) From 0d53752c49975b5210fa20279601d79ad964877c Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 13:15:52 +0800 Subject: [PATCH 071/109] refactor(host)!: retire the skill.invoke RPC for the gesture boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Invocation is an ordinary session.prompt again: the pre-step gesture boundary makes it deterministic host-side for every front end, so the dedicated RPC (handler, wire schema, error codes, client face, fixtures) and ui-skill's claim machinery are net deletions. The menu keeps decision 21 exactly — a pick lands literal /name text — plus the user-only marker from skill.list's modelInvocable flag. --- ...8-user-explicit-skill-invocation.i18n.yaml | 4 +- ...26-08-08-user-explicit-skill-invocation.md | 27 ++- ...08-08-user-explicit-skill-invocation.zh.md | 25 ++- apps/web/tests/skill-user-invoke.e2e.ts | 45 ++-- .../skill-user-invoke/ui.expected.md | 10 +- docs/config-catalog.md | 4 +- docs/cordis-catalog/events.md | 2 +- docs/cordis-catalog/services.md | 2 +- docs/event-producer-consumer.md | 2 +- .../client/connection/src/client/fixture.ts | 18 -- packages/client/connection/tests/fake-api.ts | 3 - packages/client/runtime/src/client/index.ts | 3 +- .../src/client/sessions/context-provenance.ts | 3 + .../src/client/sessions/conversation.ts | 34 --- .../src/client/sessions/transcript-adapter.ts | 20 +- packages/client/runtime/tests/fake-api.ts | 3 - .../runtime/tests/transcript-adapter.spec.ts | 27 ++- .../src/client/chat/ChatView.tsx | 10 +- .../src/client/chat/MessageItem.module.css | 27 --- .../src/client/chat/MessageItem.tsx | 39 +--- .../ui-conversation/src/client/locales.ts | 2 - .../tests/chat-branch-tails.spec.tsx | 35 --- .../src/client/turn-deliverables.ts | 3 +- .../tests/produced-files.spec.tsx | 20 -- packages/client/ui-skill/README.i18n.yaml | 4 +- packages/client/ui-skill/README.md | 10 +- packages/client/ui-skill/README.zh.md | 10 +- packages/client/ui-skill/src/client/index.ts | 69 ++---- .../ui-skill/tests/browser-plugin.spec.ts | 59 +---- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/host/apiproxy/src/api/rpc-map.ts | 1 - packages/host/apiproxy/src/api/rpc.schema.ts | 2 - packages/host/apiproxy/src/api/rpc.ts | 4 - .../host/apiproxy/src/api/skills.schema.ts | 15 -- packages/host/apiproxy/src/api/skills.ts | 22 +- packages/host/apiproxy/src/fetch/client.ts | 5 +- packages/host/apiproxy/src/fetch/handler.ts | 3 +- .../apiproxy/tests/api-proxy-commands.spec.ts | 201 ------------------ .../apiproxy/tests/client-handler.spec.ts | 2 +- .../host/apiproxy/tests/fetch-carrier.spec.ts | 5 - .../host/apiproxy/tests/rpc-schemas.spec.ts | 18 +- 43 files changed, 143 insertions(+), 663 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.i18n.yaml index 4c36032f35..3774ba6e69 100644 --- a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.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-08-08-user-explicit-skill-invocation.md -2026-08-08-user-explicit-skill-invocation.md: abe6a05283359b81ff1c3cab754d0230e599e4a0 -2026-08-08-user-explicit-skill-invocation.zh.md: e72e49236ffd2c6f664e01abbd69665eec8328e9 +2026-08-08-user-explicit-skill-invocation.md: d925938279923282170dc99934f4fa44d8ecf2b4 +2026-08-08-user-explicit-skill-invocation.zh.md: 64e23be0b42519fb9681adefcd0f05074d3aa35e diff --git a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md index abe6a05283..d925938279 100644 --- a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md +++ b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md @@ -1,4 +1,4 @@ -# Agent Note: User-explicit skill invocation over skill.invoke +# Agent Note: User-explicit skill invocation at the pre-step gesture boundary Status: implemented @@ -10,28 +10,27 @@ A `disable-model-invocation: true` skill is user-only by design: it never enters ## Decision -User-explicit invocation is a deterministic host-side injection, uniform for every user-invocable skill: +User-explicit invocation is a host-side pre-step injection, uniform for every user-invocable skill and every front end: -- `skill.invoke { sessionId, name, text? }` (host apiproxy) enforces user-invocation policy at the operation boundary (`skill-not-found` / `skill-not-invocable`), renders the skill with the shared `renderSkillContent`, appends the optional trailing text after a blank line, and injects the whole as one user-role message carrying the new `skill-invocation` `MessageSource` kind (`{ name, args? }`) before starting a turn through the same route-served gate as `session.prompt`. -- `renderSkillContent` moved from `dsh-tool-skill` to the `dsh-skill` seam: the `skill` tool result and the injection share one verbatim `` shape, and the catalog text gained the seam rule — an inline-injected skill must be followed, not re-loaded through the tool. -- `skill.list` serves every user-invocable skill and carries `modelInvocable`, so the browser menu lists user-only skills with a marker (description prefix — the `hint` field is claim-state ghost text the menu never renders). -- ui-skill claims a menu pick or an entered `/name [args]` into the invoke transaction (`matchEnter` strong-waits the catalog; unknown names stay plain prompts). The unreached legacy `name` reference codec is removed. -- The transcript materializes the injection as a dedicated `skill-invocation` node from source metadata (never re-parsed from the body) and renders a right-aligned bubble: `/name` chip, trailing text, and the injected block collapsed behind a disclosure. +- `dsh-tool-skill` registers a second `agent/pre-step` listener (beside its catalog listener, the same seam `workspace-instructions` and the runtime-context snapshot ride): it scans the step's claimed messages for whitespace-bounded `/name` tokens — anywhere in the text, the same word-boundary shape the transcript chip decoration uses — collects first-seen-deduplicated names, loads each through `ctx.skills.get`, checks `isUserInvocable` on the loaded definition (the single lookup that produces what is injected), renders it with the shared `renderSkillContent`, and appends the injections after every other injection of the step: background first (workspace rules, runtime policy, catalog), the material the model must act on last, closest to its answer. Registration order pins the placement — the gesture listener registers before the catalog listener, so the waterfall hands it the catalog-bearing list to extend. +- Precision is closed-set matching, exactly like slash commands: `/goal` resolves against the command registry, `/name` against the workspace's user-invocable skill directory; a miss stays ordinary prose, so nothing is ever guessed. Only `source.kind === 'user'` messages are scanned — external text cannot forge a gesture. Paths (`/usr/bin`), fractions (`5/8`), and prefixed tokens (`foo/name`) all break the boundary. +- The client stays decision 21: a menu pick lands the literal `/name ` and the prompt ships it verbatim; ui-skill implements no adjudication hooks and no reference codec. `skill.list` (now the domain's only RPC) serves every user-invocable skill with `modelInvocable` so menus mark user-only entries. A name shared with a host command resolves to the command — adjudication claims the line client-side before it becomes a prompt. +- The injection is a `user`-role message carrying the `skill-invocation` source (`{ name, form: 'instructions' }`), so `user/message` logging, the context-injection transcript row (labelled with the skill name), and replay all come free; `renderSkillContent` lives in the `dsh-skill` seam, shared verbatim with the `skill` tool result, and the catalog's closing sentence tells the model to follow an injected block instead of re-loading it. -Peer-product survey (Pi, OpenCode, Claude Code, Kimi Code, Codex, DeepSeek-Reasonix — local checkouts) was unanimous: user-explicit triggering is programmatic injection as a user-role message with zero model participation on every product, prompt-guided tool loading exists only on the model-autonomous track, and the disable-model-invocation equivalents gate only the model-side surfaces. Kimi's origin-metadata rendering and the Claude Code/Kimi no-reload prompt rule translate directly onto `MessageSource` and the catalog sentence. +Peer-product survey (Pi, OpenCode, Claude Code, Kimi Code, Codex, DeepSeek-Reasonix — local checkouts) was unanimous that user-explicit triggering is programmatic injection with zero model participation; the final shape is closest to Codex's core-side `$name` mention scanning, which likewise frees every front end from implementing recognition. ## Alternatives considered -- **`agent.inject()` context injection** — no peer precedent; the gesture is a user turn, not an environment notice, and context-row presentation, compaction, and attribution all mismatch. Rejected. +- **`skill.invoke` RPC (host injects, client claims)** — implemented first, in two iterations: a single mixed message (user text folded into the body), then a gesture prompt plus injection delivered through inbox primitives. Rejected after real-session testing: the mixed message polluted the injection with user prose; the two-message form depended on wake-ordering subtleties (`followup` claims the whole next-turn queue synchronously inside the first waking call, stranding any later message in the next turn — reproduced live), and the dedicated RPC duplicated a path `session.prompt` already provides while leaving TUI/ACP to reimplement recognition. The pre-step seam removes the RPC, the claim machinery, and the ordering hazard outright. +- **`agent.inject()` from the RPC handler** — the inject queue (`next-step`, wake-free) is claimed ahead of the next-turn prompt, putting the injection above the gesture in the log; and pairing it with a waking `followup` reintroduces the same ordering coupling. The pre-step listener injects inside the step assembly, where ordering is explicit. - **A host `/skill ` command** (command registry, plan-mode precedent) — two-token UX, no name completion, and user-only skills stay undiscoverable in the menu; the per-cwd skill catalog also fits the static command registry poorly. Rejected. - **Client-side expansion** (fetch body, splice into the prompt) — authorization becomes bypassable client courtesy, the log loses the invocation semantics, and Codex deleted its equivalent mechanism (custom prompts) in favor of core injection. Rejected. -- **Host prompt-pipeline scanning for `/name`** (Codex `$name` core mentions) — duplicates the adjudication layer and risks swallowing literal slashes in prose; the claim path already covers the need. Rejected. -- **Per-injection preamble line** (Kimi's `User activated the skill …`) — dropped in favor of a one-time catalog sentence: same context, paid once, and the injected block stays byte-identical with the tool result. +- **Structured reference payload on the prompt wire** (Codex's `UserInput::Skill` analogue: the client ships `{skills: [...]}` beside the text and the boundary prefers it over scanning) — considered and deferred: the existing slash-command system is itself line-text on the wire, and closed-set directory matching already removes the guesswork; recorded as a ledger item should gesture precision ever need client intent. +- **Per-injection preamble line** (Kimi's `User activated the skill …`) — dropped in favor of the one-time catalog sentence: same context, paid once, and the injected block stays byte-identical with the tool result. ## Consequences -- Decision 21's plain-text reference path is superseded at submission: the draft still carries plain text and lexicon-derived chip visuals, but submit claims into a deterministic injection instead of shipping the literal and hoping. The model-autonomous track (catalog + `skill` tool) is unchanged. -- Every user-invocable skill invocation now costs its full rendered body unconditionally — the price of determinism the peer survey showed everyone pays. +- Decision 21's plain-text reference is now the whole client story: the draft carries plain text, chip visuals derive from the lexicon, and the sent text is judged by the host boundary — a hand-typed gesture, a menu pick, and a TUI prompt are indistinguishable and equally deterministic. +- Every user-invocable skill invocation costs its full rendered body unconditionally — the price of determinism the peer survey showed everyone pays. Mentioning a known skill name mid-sentence loads it; that is the Codex mention semantic, accepted deliberately. - The `skill-invocation` source rides `user/message`, so Model-visible ⟺ logged holds with no new event type, and replay/UI read metadata rather than text markers. -- TUI and ACP can adopt `skill.invoke` later for the same semantics; until then the TUI's client-side expansion remains its own path. - Accepted residual of dropping the per-injection preamble: the no-reload framing rides only the catalog, and a workspace whose skills are all user-only never publishes a first catalog — an injection can arrive with no framing at all, and the model may redundantly try the `skill` tool once (the replacement catalog's empty arm carries the sentence; the never-published case does not). Publishing a catalog for framing alone was judged worse than that one recoverable error. diff --git a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.zh.md b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.zh.md index e72e49236f..64e23be0b4 100644 --- a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 经 skill.invoke 的用户显式 skill 调用 +# Agent Note: pre-step 手势边界上的用户显式 skill 调用 Status: implemented @@ -10,28 +10,27 @@ Status: implemented ## 决策 -用户显式调用是一次确定性的宿主侧注入,对每一个用户可调用的 skill 一致: +用户显式调用是一次宿主侧的 pre-step 注入,对每一个用户可调用的 skill 和每一种前端一致: -- `skill.invoke { sessionId, name, text? }`(宿主 apiproxy)在操作边界强制执行用户调用策略(`skill-not-found`/`skill-not-invocable`),用共享的 `renderSkillContent` 渲染该 skill,在一个空行之后追加可选的尾随文本,并把整体作为一条携带新增 `skill-invocation` `MessageSource` kind(`{ name, args? }`)的 user 角色消息注入,随后经由与 `session.prompt` 相同的「路由是否有适配器在服务」闸门开启一个轮次。 -- `renderSkillContent` 从 `dsh-tool-skill` 移入 `dsh-skill` seam:`skill` 工具结果与注入共享同一份逐字一致的 `` 形态,目录文本则新增了这条 seam 规则——已内联注入的 skill 必须被遵循,而不是再经工具重新加载。 -- `skill.list` 提供每一个用户可调用的 skill 并携带 `modelInvocable`,因此浏览器菜单会带标记地列出仅限用户的 skill(描述前缀——`hint` 字段是认领态的 ghost text,菜单从不渲染它)。 -- ui-skill 把菜单 pick 或回车提交的 `/name [args]` 认领进 invoke 事务(`matchEnter` 强等目录;未知名称保持为普通提示词)。已不可达的旧 `name` 引用 codec 被移除。 -- transcript(文本记录)依据来源元数据把这次注入物化为专用的 `skill-invocation` 节点(绝不从正文重新解析),并渲染为一个右对齐气泡:`/name` chip、尾随文本,以及收在 disclosure 之后的注入块。 +- `dsh-tool-skill` 注册第二个 `agent/pre-step` 监听器(与其目录监听器并列,也是 `workspace-instructions` 与运行时上下文快照搭乘的同一 seam):它在该步骤已认领的消息中扫描以空白为界的 `/name` token——文本中任意位置均可,与 transcript(文本记录)chip 装饰所用的词边界形状相同——收集按首见去重的名称,逐个经 `ctx.skills.get` 加载,在已加载定义上检查 `isUserInvocable`(产生注入内容的正是这同一次查找),用共享的 `renderSkillContent` 渲染,并把注入追加在该步骤所有其他注入之后:背景在前(工作区规则、运行时策略、目录),模型必须着手处理的材料在最后、最贴近它的回答。注册顺序钉住了这一位置——手势监听器先于目录监听器注册,因此 waterfall 会把携带目录的列表交给它来扩展。 +- 精确性来自封闭集合匹配,与斜杠命令完全一致:`/goal` 对照命令注册表解析,`/name` 对照工作区的用户可调用 skill 目录解析;未命中即保持为普通行文,因此绝不猜测。只扫描 `source.kind === 'user'` 的消息——外部文本无法伪造手势。路径(`/usr/bin`)、分数(`5/8`)与带前缀的 token(`foo/name`)都会破坏该边界。 +- 客户端停留在决策 21:菜单 pick 落下字面文本 `/name `,提示词将其原样发出;ui-skill 不实现任何裁决钩子,也没有引用 codec。`skill.list`(现在是该领域唯一的 RPC)提供每一个用户可调用的 skill 并携带 `modelInvocable`,供菜单标出仅限用户的条目。与宿主命令同名的名称解析为命令——裁决在客户端把该行认领走,它尚未成为提示词。 +- 注入是一条携带 `skill-invocation` 来源(`{ name, form: 'instructions' }`)的 `user` 角色消息,因此 `user/message` 落账、上下文注入的 transcript 行(以 skill 名称标注)与回放全部免费获得;`renderSkillContent` 位于 `dsh-skill` seam,与 `skill` 工具结果逐字共享,目录的结尾一句会告诉模型遵循注入块而不是重新加载。 -同类产品调研(Pi、OpenCode、Claude Code、Kimi Code、Codex、DeepSeek-Reasonix——本地检出)结论一致:在每个产品上,用户显式触发都是以 user 角色消息做程序化注入、模型零参与;提示词引导的工具加载只存在于模型自主轨道上;disable-model-invocation 的对应物只把关模型侧表层。Kimi 的来源元数据渲染与 Claude Code/Kimi 的禁止重载提示词规则,可直接平移到 `MessageSource` 与目录那句话上。 +同类产品调研(Pi、OpenCode、Claude Code、Kimi Code、Codex、DeepSeek-Reasonix——本地检出)一致表明:用户显式触发都是模型零参与的程序化注入;最终形态最接近 Codex 核心侧的 `$name` mention 扫描——它同样让每一种前端免于自行实现识别。 ## 考虑过的替代方案 -- **`agent.inject()` 上下文注入**——没有同类产品先例;这次手势是一个用户轮次,不是环境通知,而且上下文行呈现、压缩(compaction)与归属全都不匹配。否决。 +- **`skill.invoke` RPC(宿主注入、客户端认领)**——最先实现,共两轮迭代:先是单条混合消息(用户文本折进正文),后是经 inbox 原语投递的手势提示词加注入两条消息。经真实会话测试后否决:混合消息让用户行文污染了注入;两条消息的形态依赖唤醒顺序的微妙之处(`followup` 在第一个唤醒调用内同步认领整个 next-turn 队列,把之后的消息滞留到下一轮次——已实际复现),而专设 RPC 复制了 `session.prompt` 已提供的路径,还让 TUI/ACP 不得不各自重新实现识别。pre-step seam 把 RPC、认领机制与顺序隐患一并干净移除。 +- **从 RPC 处理器调用 `agent.inject()`**——inject 队列(`next-step`,不唤醒)会在 next-turn 提示词之前被认领,使注入在日志中排到手势之上;而与会唤醒的 `followup` 搭配又会重新引入同样的顺序耦合。pre-step 监听器在步骤组装内部注入,那里的顺序是显式的。 - **宿主 `/skill ` 命令**(命令注册表,plan 模式先例)——两 token 的 UX、没有名称补全、仅限用户的 skill 在菜单里仍不可发现;按 cwd 的 skill 目录也与静态命令注册表格格不入。否决。 - **客户端展开**(拉取正文、拼进提示词)——授权沦为可被绕过的客户端善意,日志失去调用语义,而且 Codex 已删除其等价机制(custom prompts)转向核心注入。否决。 -- **宿主提示词流水线扫描 `/name`**(Codex 的 `$name` core mentions)——重复了裁决层,还有吞掉普通行文中字面斜杠的风险;认领路径已经覆盖了这一需求。否决。 +- **提示词协议上的结构化引用载荷**(Codex `UserInput::Skill` 的类似物:客户端在文本旁附带 `{skills: [...]}`,边界优先采用它而不是扫描)——考虑过并暂缓:现有斜杠命令体系在协议上本身就是行文本,封闭集合的目录匹配已经消除了猜测;已记为台账事项,以备手势精确性某天需要客户端意图。 - **每次注入一条前导语**(Kimi 的 `User activated the skill …`)——弃用,改为一次性的目录句子:同样的上下文、只支付一次,且注入块与工具结果保持逐字节一致。 ## 后果 -- 决策 21 的纯文本引用路径在提交处被取代:草稿仍承载纯文本与 lexicon 派生的 chip 视觉,但提交会认领进一次确定性注入,而不是把字面文本发出去再碰运气。模型自主轨道(目录 + `skill` 工具)不变。 -- 每一次用户可调用 skill 的调用现在都无条件付出其完整渲染正文的成本——这是确定性的代价,同类调研表明所有产品都在支付。 +- 决策 21 的纯文本引用如今就是客户端的全部故事:草稿承载纯文本,chip 视觉由 lexicon 派生,发出的文本由宿主边界评判——手动键入的手势、菜单 pick 与 TUI 提示词无从区分,也同等确定。 +- 每一次用户可调用 skill 的调用都无条件付出其完整渲染正文的成本——这是确定性的代价,同类调研表明所有产品都在支付。在句子中间提到一个已知 skill 名称也会加载它;这就是 Codex 的 mention 语义,属于有意接受。 - `skill-invocation` 来源搭乘 `user/message`,因此「模型可见 ⟺ 已记录」在不新增事件类型的情况下继续成立,回放与 UI 读取的是元数据而非文本标记。 -- TUI 与 ACP 之后可以为同样的语义采用 `skill.invoke`;在那之前,TUI 的客户端展开仍是它自己的路径。 - 放弃逐次注入前导语后被接受的残余:no-reload framing 只搭乘目录,而 skill 全部为仅用户的工作区永远不会发布首个目录——注入可能在完全没有 framing 的情况下到达,模型可能多余地调用一次 `skill` 工具(替换目录的空臂携带该句;从未发布的情形没有)。仅为 framing 而发布目录被判定比这一次可恢复的错误更糟。 diff --git a/apps/web/tests/skill-user-invoke.e2e.ts b/apps/web/tests/skill-user-invoke.e2e.ts index f722472ded..2d5a039623 100644 --- a/apps/web/tests/skill-user-invoke.e2e.ts +++ b/apps/web/tests/skill-user-invoke.e2e.ts @@ -1,9 +1,9 @@ // Web e2e scenario: a user invokes a disable-model-invocation skill through // the composer (issue #1470). The entered `/name args` line claims into -// skill.invoke: the real host renders the skill body, injects it as a -// user-role message carrying the skill-invocation source, and starts a turn -// answered by the replay seam. The transcript shows the dedicated invocation -// card (chip + args, body collapsed) and the model's reply. +// skill.invoke: the real host forwards the gesture as an ordinary user +// prompt, injects the rendered body as instructions context named after the +// skill, and starts a turn answered by the replay seam. The transcript shows +// the gesture bubble, the collapsed context-injection row, and the reply. import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { fileURLToPath } from 'node:url' @@ -96,7 +96,7 @@ describe.skipIf(MODE === 'record')('web e2e: user-explicit skill invocation thro if (failures.length > 1) throw new AggregateError(failures, 'skill-user-invoke e2e cleanup failed') }) - it('claims /name args into an injection card and a replayed answer', async () => { + it('claims /name args into a gesture bubble, an injection row, and a replayed answer', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-skill-user-invoke')) const composer = page.locator('textarea:enabled').last() await composer.waitFor({ timeout: 15_000 }) @@ -112,23 +112,26 @@ describe.skipIf(MODE === 'record')('web e2e: user-explicit skill invocation thro await composer.fill(`/${SKILL_NAME} ${ARGS_TEXT}`) await composer.press('Enter') - // The injection card presents the gesture from source metadata: chip plus - // args, with the rendered collapsed behind a disclosure. - const card = page.locator('[data-skill-invocation]') - await card.waitFor({ timeout: 15_000 }) - const chip = card.locator('[data-ref-chip="skill"]') - expect(await chip.textContent()).toBe(`/${SKILL_NAME}`) - expect(await card.textContent()).toContain(ARGS_TEXT) + // The gesture stays an ordinary user bubble (decorated /name token plus + // the trailing text), ahead of the injected context. + const bubble = page.locator('[data-ref-chip="skill"]').first() + await bubble.waitFor({ timeout: 15_000 }) + expect(await bubble.textContent()).toBe(`/${SKILL_NAME}`) - const disclosure = card.locator('details') - expect(await disclosure.getAttribute('open')).toBeNull() - await card.locator('summary').click() - const body = card.locator('pre') - await body.waitFor() - expect(await body.textContent()).toContain(``) - expect(await body.textContent()).toContain('Reply with the fixture acknowledgement line.') - expect(await body.textContent()).toContain(ARGS_TEXT) - await card.locator('summary').click() + // The rendered body arrives as a context-injection row named after the + // skill; expanding it reveals the canonical block, and + // the user's text is NOT folded into it. + const injectionRow = page.getByRole('button', { name: `Context injection ${SKILL_NAME}` }) + await injectionRow.waitFor({ timeout: 15_000 }) + await injectionRow.click() + const injectionBody = page + .locator('[data-context-injection-body]') + .filter({ hasText: `` }) + await injectionBody.waitFor({ timeout: 10_000 }) + const injected = await injectionBody.textContent() + expect(injected).toContain('Reply with the fixture acknowledgement line.') + expect(injected).not.toContain(ARGS_TEXT) + await injectionRow.click() // The injection started a turn; the replay seam answers it. await page.getByText('USER_INVOKE_REPLY', { exact: false }).first().waitFor({ timeout: 20_000 }) diff --git a/apps/web/tests/snapshots/skill-user-invoke/ui.expected.md b/apps/web/tests/snapshots/skill-user-invoke/ui.expected.md index b96413f89f..c77081584a 100644 --- a/apps/web/tests/snapshots/skill-user-invoke/ui.expected.md +++ b/apps/web/tests/snapshots/skill-user-invoke/ui.expected.md @@ -1,18 +1,20 @@ - banner: - navigation "Session hierarchy": - - button "workspace" [disabled] + - button "/user-invoke-demo and confirm the fixtur" [disabled] - tablist: - tab "Chat" [selected] - tab "Trajectory" -- text: /user-invoke-demo and confirm the fixture wiring -- group: View injected skill content -- text: {{clock}} +- text: /user-invoke-demo and confirm the fixture wiring {{clock}} - button "Copy": - img - button "Context injection @deepseek-ai/dsh-system-prompt": - img - img - text: Context injection @deepseek-ai/dsh-system-prompt +- button "Context injection user-invoke-demo": + - img + - img + - text: Context injection user-invoke-demo - paragraph: USER_INVOKE_REPLY acknowledged; following the injected skill. - button "Copy": - img diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 9f1bf08f9d..470e1d4816 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1471,7 +1471,7 @@ export interface Config { } ``` -Source: [`packages/skill/skill/src/index.ts:261`](../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts:262`](../packages/skill/skill/src/index.ts) ## `@deepseek-ai/dsh-skill-local` @@ -2063,7 +2063,7 @@ export interface Config { } ``` -Source: [`packages/skill/tool-skill/src/index.ts:59`](../packages/skill/tool-skill/src/index.ts) +Source: [`packages/skill/tool-skill/src/index.ts:61`](../packages/skill/tool-skill/src/index.ts) ## `@deepseek-ai/dsh-tool-str-replace-editor` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 55952b3591..6b79018c22 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -677,7 +677,7 @@ A skill provider, runtime contribution, or provider-backed catalog may have chan 'skills/change'(): void ``` -Source: [`packages/skill/skill/src/index.ts:279`](../../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts:280`](../../packages/skill/skill/src/index.ts) ## `subagent/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 4abd00c1fd..8820a0329c 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1946,7 +1946,7 @@ async get(name: string, options: SkillLookupOptions = {}): Promise { - const missing = requireSession(request) - if (missing !== undefined) return missing - const { sessionId, name, text: args } = request.payload - const body = `\n\nBase directory for this skill: /fixture/skills/${name}\n\n\n\nFixture ${name} instructions.\n\n` - // Mirror the host: injection is a user-role message carrying the - // skill-invocation source, immediately visible in the transcript. - // The client program cannot see the host-side MessageSourceMap merge - // (sources are opaque wire JSON to the UI), so the fixture stamps the - // durable shape through the same assertion the projections read back. - const source = { kind: 'skill-invocation', name, ...args === undefined ? {} : { args } } as unknown as MessageSource - append(sessionId, { - type: 'user/message', surfaceOp: 'append', - data: userMessage(text(args === undefined ? body : `${body}\n\n${args}`), source), - }) - return ok(request, { accepted: true as const }) - }, }, goals: { // Compatibility face only: old API Proxy payloads and acknowledgements @@ -2779,7 +2762,6 @@ export class FixtureApiClient extends AbstractApiClient { case 'command.list': return this.api.commands.list(request) case 'command.execute': return this.api.commands.execute(request, signal) case 'skill.list': return this.api.skills.list(request) - case 'skill.invoke': return this.api.skills.invoke(request, signal) case 'goal.create': return this.api.goals.create(request) case 'goal.edit': return this.api.goals.edit(request) case 'goal.pause': return this.api.goals.pause(request) diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index bd8efaf6a4..cc4504e538 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -163,8 +163,6 @@ export class FakeApiClient implements IApiClient { onSkillList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ skills: [] })) - onSkillInvoke: (payload: unknown) => Promise> - = () => Promise.resolve(ok({ accepted: true as const })) readonly commands: IApiClient['commands'] = { list: (payload: unknown) => this.record('command.list', payload, this.onCommandList(payload)), @@ -173,7 +171,6 @@ export class FakeApiClient implements IApiClient { readonly skills: IApiClient['skills'] = { list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)), - invoke: (payload: unknown) => this.record('skill.invoke', payload, this.onSkillInvoke(payload)), } readonly goals: IApiClient['goals'] = { diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 3864338e28..5a1677df96 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -45,12 +45,11 @@ export { createSnapshotStore, defineStore, shallowEqual } from './contract/store export type { EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore, } from './contract/store.ts' -export { opensUserTurn } from './sessions/conversation.ts' export type { AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig, AssistantTiming, CodeSubCall, CommandNode, CompactionSummaryNode, ComposerPhase, ContextMessageNode, ConversationNode, ConversationSnapshot, ModelRetryNode, QueuedMessage, - RunningToolCall, SkillInvocationNode, + RunningToolCall, SteeringMessageNode, TodoItem, ToolResultNode, TurnErrorNode, UnknownSurfaceNode, UserMessageNode, } from './sessions/conversation.ts' export type { diff --git a/packages/client/runtime/src/client/sessions/context-provenance.ts b/packages/client/runtime/src/client/sessions/context-provenance.ts index 5d231b6bd8..6f46a510c1 100644 --- a/packages/client/runtime/src/client/sessions/context-provenance.ts +++ b/packages/client/runtime/src/client/sessions/context-provenance.ts @@ -83,6 +83,9 @@ export function contextProvenance(source: unknown): ContextProvenanceView { return { role: 'inject', label: joined(collect(record, 'changes', 'path')) ?? kind } case 'plugin': return { role: 'inject', label: readString(record, 'plugin') ?? kind } + // A user-explicit skill invocation names the skill it injected. + case 'skill-invocation': + return { role: 'inject', label: readString(record, 'name') ?? kind } // Documented default arm of the merge-extensible source map: an unknown // producer still identifies itself by its own durable kind. default: diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 1ced1b916e..fb2c281331 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -129,25 +129,6 @@ export interface ContextMessageNode { form: KnownContextForm | null } -/** - * A user-explicit skill invocation: the host injected the rendered skill as a - * user message carrying the `skill-invocation` source, so the card presents - * `/name args` from source metadata and collapses the injected body. - */ -export interface SkillInvocationNode { - kind: 'skill-invocation' - seq: number - /** Unix epoch ms from the source session event. */ - time: number - /** Invoked skill name read off the message source. */ - name: string - /** Trailing user text read off the message source, when recorded. */ - args?: string - /** Full injected model-facing content (collapsed by default in the UI). */ - content: readonly ContentBlock[] - source: unknown -} - /** Durable notice that a closed failed step is waiting for a model-request retry. */ export type ModelRetryNode = LlmRetryEventData & { kind: 'model-retry' @@ -258,27 +239,12 @@ export interface CommandNode { outcome: { kind: 'success' | 'error'; text?: string } | null } -/** - * Whether a node opens a user turn on the transcript surface. A direct user - * message and a user-explicit skill invocation both start the turn the next - * assistant answer closes; parallel consumers (turn boundaries, retry - * liveness, own-words scrolling) share this one predicate instead of each - * re-encoding the kind list. Steering stays out: an interjection lands - * mid-turn and closes nothing. - * @param node - any conversation node. - * @returns true for the user-turn-opening kinds. - */ -export function opensUserTurn(node: Pick): boolean { - return node.kind === 'user' || node.kind === 'skill-invocation' -} - /** Finalized conversation node union (kind discriminates; seq is the React key). */ export type ConversationNode = | UserMessageNode | AssistantMessageNode | SteeringMessageNode | ContextMessageNode - | SkillInvocationNode | ModelRetryNode | TurnErrorNode | ToolResultNode diff --git a/packages/client/runtime/src/client/sessions/transcript-adapter.ts b/packages/client/runtime/src/client/sessions/transcript-adapter.ts index 4a05afee06..8b77807c96 100644 --- a/packages/client/runtime/src/client/sessions/transcript-adapter.ts +++ b/packages/client/runtime/src/client/sessions/transcript-adapter.ts @@ -58,22 +58,10 @@ function materializeNode( ): ConversationNode { switch (event.type) { case 'user/message': { - // A user-explicit skill invocation carries its name (and optional args) - // on the source; the dedicated node lets the card render `/name args` - // from metadata instead of re-parsing the injected body. A record whose - // name is unreadable degrades to injected context below. - const source = event.data.source as { kind?: unknown; name?: unknown; args?: unknown } - if (source.kind === 'skill-invocation' && typeof source.name === 'string') { - return { - kind: 'skill-invocation', seq: event.seq, time: event.time, - name: source.name, - ...typeof source.args === 'string' ? { args: source.args } : {}, - content: event.data.content, source: event.data.source, - } - } - // Injected context (plugin/goal source) folds to a context node, not a - // user message; only a direct human prompt is a user node. A compaction - // checkpoint never reaches here (isCompactCheckpoint routes it away). + // Injected context (plugin/goal/skill-invocation source) folds to a + // context node, not a user message; only a direct human prompt is a + // user node. A compaction checkpoint never reaches here + // (isCompactCheckpoint routes it away). if (event.data.source.kind !== 'user') { return { kind: 'context', seq: event.seq, time: event.time, diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index def535a59a..2f4299ce6c 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -198,8 +198,6 @@ export class FakeApiClient implements IApiClient { onSkillList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ skills: [] })) - onSkillInvoke: (payload: unknown) => Promise> - = () => Promise.resolve(ok({ accepted: true as const })) readonly commands: IApiClient['commands'] = { list: (payload: unknown) => this.record('command.list', payload, this.onCommandList(payload)), @@ -208,7 +206,6 @@ export class FakeApiClient implements IApiClient { readonly skills: IApiClient['skills'] = { list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)), - invoke: (payload: unknown) => this.record('skill.invoke', payload, this.onSkillInvoke(payload)), } readonly goals: IApiClient['goals'] = { diff --git a/packages/client/runtime/tests/transcript-adapter.spec.ts b/packages/client/runtime/tests/transcript-adapter.spec.ts index e847c2cec7..a5b423c58d 100644 --- a/packages/client/runtime/tests/transcript-adapter.spec.ts +++ b/packages/client/runtime/tests/transcript-adapter.spec.ts @@ -164,29 +164,26 @@ describe('TranscriptAdapter', () => { expect(adapter.nodes().map(node => node.kind)).toEqual(['user', 'user', 'context']) }) - it('materializes a skill-invocation source as its dedicated node', () => { + it('materializes a skill-invocation injection as a named instructions context', () => { const adapter = new TranscriptAdapter() adapter.reset([ at(0, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({ - content: [{ type: 'text', text: 'body\n\ncheck the fixture' }], - source: { kind: 'skill-invocation', name: 'hidden-demo', args: 'check the fixture' } as never, + content: [{ type: 'text', text: '/hidden-demo check the fixture' }], + source: { kind: 'user' }, }) }), at(1, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({ - content: [{ type: 'text', text: 'body' }], - source: { kind: 'skill-invocation', name: 'bare-skill' } as never, + content: [{ type: 'text', text: 'body' }], + source: { kind: 'skill-invocation', name: 'hidden-demo', form: 'instructions' } as never, }) }), ]) const nodes = adapter.nodes() - expect(nodes.map(node => node.kind)).toEqual(['skill-invocation', 'skill-invocation']) - expect(nodes[0]).toMatchObject({ name: 'hidden-demo', args: 'check the fixture' }) - expect(nodes[1]).toMatchObject({ name: 'bare-skill' }) - expect((nodes[1] as { args?: string }).args).toBeUndefined() - // A malformed record (no readable name) degrades to injected context, not a crash. - adapter.append(at(2, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({ - content: [{ type: 'text', text: 'odd' }], - source: { kind: 'skill-invocation' } as never, - }) })) - expect(adapter.nodes().at(-1)?.kind).toBe('context') + // The gesture stays a user bubble; the injected body folds to a context + // row named after the skill, presented as instructions. + expect(nodes.map(node => node.kind)).toEqual(['user', 'context']) + expect(nodes[1]).toMatchObject({ + provenance: { role: 'inject', label: 'hidden-demo' }, + form: 'instructions', + }) }) it('skips events core does not call surface-eligible, marker or not', () => { diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index a841ba6751..b0907f5a80 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -24,7 +24,6 @@ import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode, } from 'react' -import { opensUserTurn } from '@deepseek-ai/dsh-client-runtime/client' import type { CodeSubCall, CommandNode, ConversationNode, ConversationSnapshot, RunningToolCall, ToolResultNode, } from '@deepseek-ai/dsh-client-runtime/client' @@ -119,7 +118,7 @@ function activeRetrySeq(nodes: readonly ConversationNode[], running: boolean): n const node = nodes[index] if (node === undefined) continue if (node.kind === 'model-retry') return node.retryState === 'cancelled' ? null : node.seq - if (node.kind === 'assistant' || opensUserTurn(node)) return null + if (node.kind === 'assistant' || node.kind === 'user') return null } return null } @@ -448,11 +447,10 @@ export function ChatView({ return } firstSeqRef.current = firstSeq - // Own words must be visible: a new trailing user-turn node (a prompt or an - // explicit skill invocation) force-scrolls (send lives in the composer, so - // arrival is detected here, not armed there). + // Own words must be visible: a new trailing user node force-scrolls + // (send lives in the composer, so arrival is detected here, not armed there). const appendedUser = lastKey !== lastKeyRef.current - && lastItem !== undefined && lastItem.kind === 'node' && opensUserTurn(lastItem.node) + && lastItem !== undefined && lastItem.kind === 'node' && lastItem.node.kind === 'user' const appendedSteering = lastSteeringId !== null && lastSteeringId !== lastSteeringIdRef.current const tipMoved = followSigRef.current !== followSig lastKeyRef.current = lastKey diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css index 4330cde32c..5c07ace71e 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css @@ -256,30 +256,3 @@ white-space: nowrap; vertical-align: baseline; } - -/* User-explicit skill invocation: the injected body collapses behind a - disclosure inside the user bubble. */ -.skillInvocationDetails { - margin-top: 6px; -} - -.skillInvocationSummary { - cursor: pointer; - font-size: 0.8em; - color: var(--dsw-alias-label-secondary); - user-select: none; -} - -.skillInvocationBody { - margin: 6px 0 0; - padding: 8px; - max-height: 320px; - overflow: auto; - border-radius: 6px; - background: var(--dsw-alias-bg-secondary, rgba(0, 0, 0, 0.06)); - font-family: var(--dsw-font-mono, monospace); - font-size: 0.78em; - line-height: 1.5; - white-space: pre-wrap; - word-break: break-word; -} diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index af2afd9792..30b51b3870 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -7,8 +7,8 @@ import { memo, useEffect, useMemo, useState } from 'react' import type { ReactNode } from 'react' import type { - CompactionSummaryNode, ContextMessageNode, ModelRetryNode, SkillInvocationNode, - SteeringMessageNode, TurnErrorNode, UnknownSurfaceNode, UserMessageNode, + CompactionSummaryNode, ContextMessageNode, ModelRetryNode, SteeringMessageNode, + TurnErrorNode, UnknownSurfaceNode, UserMessageNode, } from '@deepseek-ai/dsh-client-runtime/client' import { JsonBlock, MessageText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps } from '../contract/slots.ts' @@ -22,7 +22,6 @@ export interface MessageItemProps { | UserMessageNode | SteeringMessageNode | ContextMessageNode - | SkillInvocationNode | CompactionSummaryNode | ModelRetryNode | TurnErrorNode @@ -192,38 +191,6 @@ function UserStyleBubble({ ) } -/** - * A user-explicit skill invocation: the right-aligned bubble presents the - * `/name args` gesture from source metadata (never re-parsed from the body), - * and the injected `` collapses behind a disclosure — the - * durable content is model-facing bulk, not conversation prose. - */ -function SkillInvocationRow({ node, t }: { - node: SkillInvocationNode - t: ChatViewSlotProps['t'] -}): ReactNode { - const { text } = contentText(node.content) - return ( -
-
- {`/${node.name}`} - {node.args !== undefined && } -
- {t('message.skillInvocation.expand')} -
{text}
-
-
- -
- ) -} - /** * Render one Host-authoritative pending steering item with the same visual * language as its eventual durable transcript node. @@ -285,8 +252,6 @@ export const MessageItem = memo(function MessageItem({ t={t} /> ) - case 'skill-invocation': - return case 'compaction': return case 'model-retry': diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index a340a2f634..df107d2cd2 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -79,7 +79,6 @@ export const zh = { 'message.context.recall.counts': '保留 {retained} 条 · 省略 {omitted} 条', 'message.context.recall.truncated': '已截断', 'message.steering': '插话', - 'message.skillInvocation.expand': '查看注入的 skill 内容', 'message.compaction': '上下文已压缩', 'message.compaction.expand': '点击查看压缩摘要', 'message.compaction.unavailable': '压缩摘要不可用', @@ -220,7 +219,6 @@ export const en = { 'message.context.recall.counts': '{retained} kept · {omitted} omitted', 'message.context.recall.truncated': 'truncated', 'message.steering': 'Interjection', - 'message.skillInvocation.expand': 'View injected skill content', 'message.compaction': 'Context compacted', 'message.compaction.expand': 'View compaction summary', 'message.compaction.unavailable': 'Compaction summary unavailable', diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index 9471461cda..28b0501141 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -865,41 +865,6 @@ describe('MessageItem arms', () => { expect(view.getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 1s') }) - it('skill-invocation renders the /name chip, args, and a collapsed injected body', () => { - const body = 'instructions\n\ncheck the fixture' - const view = render( - , - ) - const chip = view.container.querySelector('[data-ref-chip="skill"]') - expect(chip?.textContent).toBe('/hidden-demo') - const details = view.container.querySelector('details') - expect(details).toBeTruthy() - expect(details?.open).toBe(false) - expect(view.getByText('查看注入的 skill 内容')).toBeTruthy() - expect(view.container.querySelector('pre')?.textContent).toBe(body) - expect(view.container.querySelector('[data-skill-invocation]')).toBeTruthy() - }) - - it('skill-invocation without args renders only the chip line', () => { - const view = render( - x
' }] as never, - source: null, - }} - />, - ) - const bubble = view.container.querySelector('[data-skill-invocation]') - expect(bubble?.textContent).toContain('/bare-skill') - expect(bubble?.textContent).not.toContain('undefined') - }) }) describe('formatMessageClock', () => { diff --git a/packages/client/ui-deliverables/src/client/turn-deliverables.ts b/packages/client/ui-deliverables/src/client/turn-deliverables.ts index b8886be0df..c9754d1da4 100644 --- a/packages/client/ui-deliverables/src/client/turn-deliverables.ts +++ b/packages/client/ui-deliverables/src/client/turn-deliverables.ts @@ -3,7 +3,6 @@ * nodes. Client-only and model-free: the vocabulary is the mutation tools' * own follow-along `locations`, never the closing prose. */ -import { opensUserTurn } from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationNode, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -63,7 +62,7 @@ export function producedForClosing(nodes: readonly ConversationNode[], seq: numb } continue } - if (opensUserTurn(node)) { + if (node.kind === 'user') { turn = undefined pending = [] seen = new Set() diff --git a/packages/client/ui-deliverables/tests/produced-files.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.spec.tsx index 473defc4e6..845e6099c8 100644 --- a/packages/client/ui-deliverables/tests/produced-files.spec.tsx +++ b/packages/client/ui-deliverables/tests/produced-files.spec.tsx @@ -73,26 +73,6 @@ describe('producedForClosing derivation', () => { expect(producedForClosing(nodes, 999)).toEqual([]) }) - it('treats a user-explicit skill invocation as a turn boundary', () => { - // The injection opens a user turn exactly like a typed prompt: files - // written before it must not spill into the turn its answer closes. - const skillInvocation = { - kind: 'skill-invocation' as const, seq: 4, time: 4_000, - name: 'hidden-demo', - content: [{ type: 'text', text: 'x' }] as never, - source: null, - } - const nodes: ConversationNode[] = [ - user(1, 'write things'), - assistant(2, 'wrote', 1), - wrote(3, 'a', 'stale.txt'), - skillInvocation, - wrote(5, 'b', 'fresh.txt'), - assistant(6, 'followed the skill', 2), - ] - expect(producedForClosing(nodes, 6)).toEqual(['fresh.txt']) - expect(producedForClosing(nodes, 6)).not.toContain('stale.txt') - }) it('counts a generic edit and never spills across the turn boundary', () => { const inserted = (seq: number, callId: string, path: string): ToolResultNode => ({ diff --git a/packages/client/ui-skill/README.i18n.yaml b/packages/client/ui-skill/README.i18n.yaml index 5b80baa912..a1d1a2c9d5 100644 --- a/packages/client/ui-skill/README.i18n.yaml +++ b/packages/client/ui-skill/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-skill/README.md -README.md: ea3dbf3592995903422ec951e20c911082370dbe -README.zh.md: 5b8886e67973af9a594ff6aa2e9295f112a9f3e3 +README.md: bdd772662acda1f8cf1b7d8a7c5532f9b37123dd +README.zh.md: 959ff0ede6d545150fb22710c8af75859966caa9 diff --git a/packages/client/ui-skill/README.md b/packages/client/ui-skill/README.md index ea3dbf3592..bdd772662a 100644 --- a/packages/client/ui-skill/README.md +++ b/packages/client/ui-skill/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Skill invocation source, browser half: registers the `/`-trigger `skill` source into `ctx.slash`. Ordinary-session candidates come from the `skill.list` RPC addressed by the per-call `ClientSessionContext` projection's `{sessionId}`, with the host resolving `cwd` from the session header. The host serves every user-invocable skill; a `modelInvocable: false` entry (a `disable-model-invocation` skill, whose only entry point is this path) wears the user-only marker as a description prefix in the active language. Catalog-addressed continuable children resolve no skill candidates locally because the existing skill RPC requires an attached session; viewing their persisted history must not activate them. Catalogs cache per ordinary session with a single-flight fetch; the scope-birth `warm` hook prewarms the session's entry and `connection/reset` clears everything. Results filter by `startsWith(query)`. -A menu pick or an entered `/name [args]` line claims the composer into an args-tolerant `skill.invoke` transaction (`matchEnter` strong-waits the catalog; an unknown name answers undefined and stays a plain prompt). A skill name shared with a host command resolves to the command: adjudication polls sources in registration order and the web bundle mounts ui-command ahead of this source — deliberate precedence, matching peer products. Submit trims the args, keeps blank args off the wire, and folds an RPC refusal into the composer's error outcome; the host renders the skill body and injects it as a user message before starting the turn, so invocation is deterministic for every user-invocable skill. The RPC rides the plugin's root-context connection captured at registration — the source never reads services off a per-call argument. Draft chip visuals still derive from the `lexicon` scan; the legacy `name` reference codec is gone (decision 21 removal cut) and `matchSpace` stays unimplemented — menu and enter own the skill flows. +A pick lands the literal `/name ` text and the prompt ships the same literal (decision 21) — this source implements no adjudication hooks and no reference codec (the legacy `name` form is gone with the removal cut). Determinism lives host-side: the pre-step gesture boundary (`dsh-tool-skill`) recognizes whitespace-bounded `/name` tokens naming user-invocable skills anywhere in a user message and injects the rendered `` for every front end, so a menu pick, a hand-typed token, and a TUI/ACP prompt all load the skill the same way. A name shared with a host command still resolves to the command: adjudication claims the line client-side before it ever becomes a prompt — deliberate precedence, matching peer products. The list RPC rides the plugin's root-context connection captured at registration — the source never reads services off a per-call argument; draft chip visuals derive from the `lexicon` scan. A failed `skill.list` throws from `candidates`, which the slash shell logs and folds into a silent menu-group drop — the menu shows only pending/ready states. @@ -20,11 +20,11 @@ The browser plugin also registers a keyed `skill` toolview in `conversation.chat #### What the model sees -A claimed invocation never ships the `/name` literal. The host (`skill.invoke`) renders the canonical `` block — the same `renderSkillContent` output the `skill` tool returns — appends the user's trailing text after a blank line, and injects the whole as one user-role message carrying the `skill-invocation` source, immediately starting a turn. Loading is deterministic: the model receives the full body without being asked to call the `skill` tool, and the catalog (rendered by `dsh-tool-skill`) tells it not to re-load an inline-injected skill. +The user's message reaches the model verbatim, `/name` literal included. The host's pre-step boundary (`dsh-tool-skill`) then appends the canonical `` block — the same `renderSkillContent` output the `skill` tool returns — as injected instructions context at the end of that step's injections, closest to the model's answer. Loading is deterministic: the model receives the full body without being asked to call the `skill` tool, and the catalog tells it not to re-load an inline-injected skill. #### Token effect -One invocation adds the rendered skill body plus the trailing text to that turn's user message — the same cost as the model loading the skill through the tool, paid unconditionally instead of at the model's discretion. Menu browsing and the candidate fetch add zero model tokens. +One invocation adds the rendered skill body to that turn as injected context — the same cost as the model loading the skill through the tool, paid unconditionally instead of at the model's discretion. Menu browsing and the candidate fetch add zero model tokens. #### KV Cache effect @@ -33,5 +33,5 @@ Append-only: the injected message lands after the reusable history prefix. This ## Known Limitations and Deferred Work - **Result-only history pages use the generic row** — keyed dispatch needs the paired call in the runtime window; pagination that leaves the call outside has no tool identity. This client presentation feature does not extend the history wire contract to recover it. -- **Enter waits on the catalog once** — `matchEnter` strong-waits the session's first catalog fetch before answering, so an enter racing a cold cache resolves against the settled catalog rather than silently missing. A menu opened before the prewarm settles still shows no skill candidates for that keystroke. -- **Text is the truth** — the reference is plain draft text; a hand-typed identical token is the same reference. Chip visuals derive from the lexicon scan; no occurrence identity or position tracking (componentized chips are a ledger item). +- **Text is the truth** — the reference is plain draft text; a hand-typed identical token is the same reference, and the host gesture boundary judges the sent text, not the menu interaction. Chip visuals derive from the lexicon scan; no occurrence identity, position tracking, or structured reference payload on the prompt wire (both are ledger items). +- **A menu opened before the prewarm settles** shows no skill candidates for that keystroke; the next keystroke re-polls the settled cache. diff --git a/packages/client/ui-skill/README.zh.md b/packages/client/ui-skill/README.zh.md index 5b8886e679..959ff0ede6 100644 --- a/packages/client/ui-skill/README.zh.md +++ b/packages/client/ui-skill/README.zh.md @@ -4,7 +4,7 @@ skill(技能)调用 source 的浏览器端:把 `/` 触发的 `skill` source 注册进 `ctx.slash`。普通会话的候选来自 `skill.list` RPC,以每次调用的 `ClientSessionContext` 投影中的 `{sessionId}` 寻址,host 从会话 header 解析 `cwd`。宿主提供每一个用户可调用的 skill;`modelInvocable: false` 的条目(即 `disable-model-invocation` skill,此路径是其唯一入口)会以当前语言把仅限用户标记作为描述前缀带上。由目录寻址的可继续 subagent 在客户端解析为没有 skill 候选,因为现有 skill RPC 要求会话已挂载;查看其持久化历史不得激活它。目录按普通会话缓存,拉取走 single-flight;scope 创建时的 `warm` 钩子预热该会话的缓存项,`connection/reset` 清空全部缓存。结果按 `startsWith(query)` 过滤。 -菜单 pick 或回车提交的一行 `/name [args]` 会把 composer 认领进一个容忍参数的 `skill.invoke` 事务(`matchEnter` 强等目录;未知名称应答 undefined,保持为普通提示词)。与宿主命令同名的 skill 名解析为命令:裁决按注册顺序轮询各 source,而 web bundle 把 ui-command 挂载在本 source 之前——这是有意的优先级,与同行产品一致。提交时会修剪参数、让空白参数不上协议,并把 RPC 拒绝折叠进 composer 的错误结局;宿主在开启轮次之前渲染 skill 正文并将其作为用户消息注入,因此对每一个用户可调用的 skill,调用都是确定性的。RPC 使用插件注册时捕获的根上下文连接——source 绝不从每次调用的参数上读取服务。草稿 chip 视觉仍由 `lexicon` 扫描派生;旧的 `name` 引用 codec 已经移除(决策 21 的移除裁定),`matchSpace` 保持不实现——skill 流程归菜单与回车所有。 +pick 会落下字面文本 `/name `,提示词发出的就是同一段字面文本(决策 21)——本 source 不实现任何裁决钩子,也没有引用 codec(旧的 `name` 形式已随移除裁定消失)。确定性在宿主侧:pre-step 手势边界(`dsh-tool-skill`)识别用户消息中任意位置、以空白为界、指名用户可调用 skill 的 `/name` token,并为每一种前端注入渲染后的 ``,因此菜单 pick、手动键入的 token 与 TUI/ACP 提示词都以同一种方式加载 skill。与宿主命令同名的名称仍解析为命令:裁决在客户端把该行认领走,它根本不会成为提示词——这是有意的优先级,与同行产品一致。列表 RPC 使用插件注册时捕获的根上下文连接——source 绝不从每次调用的参数上读取服务;草稿 chip 视觉由 `lexicon` 扫描派生。 `skill.list` 失败时 `candidates` 抛出异常,slash 壳层记录日志并折叠为静默的菜单组丢弃——菜单只显示 pending/ready 状态。 @@ -20,11 +20,11 @@ skill(技能)调用 source 的浏览器端:把 `/` 触发的 `skill` sourc #### 模型看到的内容 -被认领的调用绝不会把字面文本 `/name` 发出去。宿主(`skill.invoke`)渲染规范的 `` 块——与 `skill` 工具返回的 `renderSkillContent` 输出相同——在一个空行之后追加用户的尾随文本,并把整体作为一条携带 `skill-invocation` 来源的 user 角色消息注入,随即开启一个轮次。加载是确定性的:模型无需被要求调用 `skill` 工具就能收到完整正文,目录(由 `dsh-tool-skill` 渲染)也会告诉它不要重新加载已内联注入的 skill。 +用户消息原样到达模型,字面文本 `/name` 也包含在内。随后宿主的 pre-step 边界(`dsh-tool-skill`)把规范的 `` 块——与 `skill` 工具返回的 `renderSkillContent` 输出相同——作为注入的指令上下文追加在该步骤各项注入的末尾,最贴近模型的回答。加载是确定性的:模型无需被要求调用 `skill` 工具就能收到完整正文,目录也会告诉它不要重新加载已内联注入的 skill。 #### Token 影响 -一次调用会把渲染后的 skill 正文连同尾随文本加进该轮次的用户消息——成本与模型经由工具加载该 skill 相同,只是无条件支付,而非由模型自行裁量。浏览菜单和拉取候选不会增加任何模型 token。 +一次调用会把渲染后的 skill 正文作为注入上下文加进该轮次——成本与模型经由工具加载该 skill 相同,只是无条件支付,而非由模型自行裁量。浏览菜单和拉取候选不会增加任何模型 token。 #### KV Cache 影响 @@ -33,5 +33,5 @@ skill(技能)调用 source 的浏览器端:把 `/` 触发的 `skill` sourc ## 已知限制与暂缓事项 - **仅含结果的 history 页使用通用行**:键控分派要求配对调用位于 runtime 窗口内;分页将调用留在窗口外时,结果没有工具身份。这项客户端呈现功能不会为了恢复该身份而扩展 history 协议契约。 -- **回车对目录只等待一次**:`matchEnter` 在应答之前强等该会话的首次目录拉取,因此与冷缓存竞速的回车会对照已落定的目录解析,而不是静默错过。预热落定之前打开的菜单,在那次击键下仍不会显示 skill 候选。 -- **文本是唯一依据**:引用是普通的草稿文本;手动键入的相同 token 就是同一个引用。chip 视觉由 lexicon 扫描派生;没有 occurrence 身份或位置跟踪(组件化 chip 是台账事项)。 +- **文本是唯一依据**:引用是普通的草稿文本;手动键入的相同 token 就是同一个引用,宿主手势边界评判的是发出的文本,而不是菜单交互。chip 视觉由 lexicon 扫描派生;没有 occurrence 身份、位置跟踪,也没有提示词协议上的结构化引用载荷(两者都是台账事项)。 +- **预热落定之前打开的菜单**:在那次击键下不显示 skill 候选;下一次击键会重新轮询已落定的缓存。 diff --git a/packages/client/ui-skill/src/client/index.ts b/packages/client/ui-skill/src/client/index.ts index a73370b8ff..4e23be06be 100644 --- a/packages/client/ui-skill/src/client/index.ts +++ b/packages/client/ui-skill/src/client/index.ts @@ -2,15 +2,16 @@ * Skill reference plugin, browser half: registers the '/' skill source — * candidates from the skill.list RPC addressed by the per-call session * projection's sessionId (sessions are always agent-backed; the host - * resolves cwd from the session header). A menu pick or an entered `/name - * [args]` line claims into a skill.invoke transaction: the host renders the - * skill body and injects it as a user message, so invocation is - * deterministic for every user-invocable skill — including - * `disable-model-invocation` skills the model-side catalog never lists - * (issue #1470). The RPC rides the plugin's root-context connection - * captured at registration — the source never reads services off a per-call - * argument. Draft chip visuals still derive from the lexicon scan; the - * legacy `` reference codec is gone (decision 21 removal cut). + * resolves cwd from the session header). A pick lands the literal `/name ` + * text and the prompt ships the same literal (decision 21); determinism + * lives host-side — the pre-step boundary (`dsh-tool-skill`) recognizes a + * leading `/name` naming a user-invocable skill and injects the rendered + * body for every front end, including `disable-model-invocation` skills the + * model-side catalog never lists (issue #1470). The RPC rides the plugin's + * root-context connection captured at registration — the source never reads + * services off a per-call argument. Draft chip visuals still derive from + * the lexicon scan; the legacy `` reference codec is gone (decision + * 21 removal cut). * * Catalog fetches are cached per session (the small twin of the ui-command * directory): the per-keystroke candidates re-poll filters a settled @@ -27,7 +28,7 @@ */ import type { ConnectionHandle, SessionId, SkillEntry } from '@deepseek-ai/dsh-client-connection/client' import type { ClientContext, ISessions } from '@deepseek-ai/dsh-client-runtime/client' -import type { PickOutcome, SlashServiceContract, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client' +import type { SlashServiceContract, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' import { SkillRow } from './SkillRow.tsx' @@ -125,27 +126,6 @@ export function apply(ctx: ClientContext): void { // locale service's own fallback ladder; candidate-time reads stay plain text. const t = ctx.locale.bind(NS) - /** - * Args-tolerant claim for one skill: token `/name ` plus the skill.invoke - * transaction. Blank args stay off the wire; an RPC refusal folds into the - * composer's error outcome (transport failures throw). - */ - const invokeClaim = (session: { readonly sessionId: SessionId }, name: string): PickOutcome => ({ - claim: { - token: `/${name} `, - submit: async (args) => { - const trimmed = args.trim() - const { result } = await skills.invoke({ - sessionId: session.sessionId, - name, - ...trimmed === '' ? {} : { text: trimmed }, - }) - if (!result.ok) return { kind: 'error', text: `${result.error.code}: ${result.error.message}` } - return { kind: 'success' } - }, - }, - }) - const source: SlashSource = { trigger: '/', name: 'skill', @@ -181,25 +161,14 @@ export function apply(ctx: ClientContext): void { if (listeners.size === 0) lexiconListeners.delete(key) } }, - onPick({ candidate, session }) { - return invokeClaim(session, candidate.name) - }, - // Adjudication polls sources in registration order and the web bundle - // mounts ui-command first, so a name shared with a host command claims as - // the command — deliberate precedence (commands are explicit host - // features; peer products resolve the collision the same way), not a race. - async matchEnter(session, line, signal) { - const trimmed = line.trim() - if (!trimmed.startsWith('/')) return undefined - const ws = trimmed.search(/\s/) - const name = (ws === -1 ? trimmed : trimmed.slice(0, ws)).slice(1) - if (name === '') return undefined - // Strong-wait the catalog: an unknown name stays a plain prompt (the - // default sink), never a swallowed line. - const catalog = await fetchCatalog(session.sessionId) - if (signal.aborted) return undefined - if (!catalog.some(skill => skill.name === name)) return undefined - return invokeClaim(session, name) + onPick({ candidate }) { + // Decision 21: the pick lands plain text and the prompt ships the same + // literal. Determinism no longer rides the client — the host's + // pre-step boundary (dsh-tool-skill) recognizes the leading /name and + // injects the rendered body for every front end. A name shared with a + // host command still resolves to the command: adjudication claims the + // line client-side before it ever becomes a prompt. + return { text: `/${candidate.name} ` } }, } const slash = ctx.get('slash') as SlashServiceContract diff --git a/packages/client/ui-skill/tests/browser-plugin.spec.ts b/packages/client/ui-skill/tests/browser-plugin.spec.ts index da99ed70d3..f73a8d8bda 100644 --- a/packages/client/ui-skill/tests/browser-plugin.spec.ts +++ b/packages/client/ui-skill/tests/browser-plugin.spec.ts @@ -322,10 +322,9 @@ describe('lexicon', () => { }) }) -describe('pick claims into skill.invoke', () => { - it('onPick returns an args-tolerant claim whose submit invokes the skill', async () => { - const invoke = vi.fn(() => Promise.resolve({ result: { ok: true as const, value: { accepted: true as const } } })) - const { source } = await bench(listOk(CATALOG), undefined, invoke) +describe('pick lands plain text (decision 21)', () => { + it('onPick returns the literal /name text with a closing space', async () => { + const { source } = await bench(listOk(CATALOG)) const outcome = source.onPick({ candidate: { name: 'commit-helper', description: 'commit flow' }, session: proj('s1'), @@ -333,58 +332,16 @@ describe('pick claims into skill.invoke', () => { via: 'menu', span: { start: 0, end: 4, draftRev: 7 }, }) - if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected a claim outcome') - expect(outcome.claim.token).toBe('/commit-helper ') - await expect(outcome.claim.submit('check the fixture', {} as never)).resolves.toEqual({ kind: 'success' }) - expect(invoke).toHaveBeenCalledWith({ sessionId: sid('s1'), name: 'commit-helper', text: 'check the fixture' }) + expect(outcome).toEqual({ text: '/commit-helper ' }) }) - it('submit omits blank args and folds an RPC refusal into an error outcome', async () => { - const invoke = vi.fn(() => Promise.resolve({ - result: { ok: false as const, error: { code: 'skill-not-invocable', message: 'nope', details: { name: 'deploy' } } }, - })) - const { source } = await bench(listOk(CATALOG), undefined, invoke) - const outcome = source.onPick({ - candidate: { name: 'deploy', description: 'deploy flow' }, - session: proj('s1'), - position: 'leading', - via: 'menu', - span: { start: 0, end: 4, draftRev: 7 }, - }) - if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected a claim outcome') - await expect(outcome.claim.submit(' ', {} as never)) - .resolves.toEqual({ kind: 'error', text: 'skill-not-invocable: nope' }) - expect(invoke).toHaveBeenCalledWith({ sessionId: sid('s1'), name: 'deploy' }) - }) - - it('drops the legacy reference codec (decision 21 removal cut)', async () => { + it('keeps the legacy reference codec removed and stays out of adjudication', async () => { const { source } = await bench(listOk(CATALOG)) + // Determinism lives host-side (the pre-step gesture boundary), so the + // source neither claims lines nor serializes reference markup. expect(source.codec).toBeUndefined() - }) -}) - -describe('adjudication', () => { - it('claims an entered /name line, args-tolerant, once the catalog knows the name', async () => { - const invoke = vi.fn(() => Promise.resolve({ result: { ok: true as const, value: { accepted: true as const } } })) - const { source } = await bench(listOk(CATALOG), undefined, invoke) - const outcome = await source.matchEnter!(proj('s1'), '/deploy run the smoke suite', new AbortController().signal) - if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected a claim outcome') - expect(outcome.claim.token).toBe('/deploy ') - await outcome.claim.submit('run the smoke suite', {} as never) - expect(invoke).toHaveBeenCalledWith({ sessionId: sid('s1'), name: 'deploy', text: 'run the smoke suite' }) - }) - - it('answers undefined for unknown names, non-slash lines, and bare "/"', async () => { - const { source } = await bench(listOk(CATALOG)) - const signal = new AbortController().signal - await expect(source.matchEnter!(proj('s1'), '/unlisted do it', signal)).resolves.toBeUndefined() - await expect(source.matchEnter!(proj('s1'), 'plain prose', signal)).resolves.toBeUndefined() - await expect(source.matchEnter!(proj('s1'), '/', signal)).resolves.toBeUndefined() - }) - - it('never claims on space (menu and enter own the skill flows)', async () => { - const { source } = await bench(listOk(CATALOG)) expect(typeof source.matchSpace).toBe('undefined') + expect(typeof source.matchEnter).toBe('undefined') }) }) diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 017bd32970..961b48dd0b 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/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/host/apiproxy/README.md -README.md: 8d7a24b0b8b897d94ed29d5dc9ed6e9efb250fc6 -README.zh.md: c988b7540ba719d02e50d6da9595353c93766835 +README.md: 5506cbef7b778a870e1e28c3f9fdf1713f89d65f +README.zh.md: de31f653944097e9b47a966f56c418dc9fa9b1b9 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 8d7a24b0b8..5506cbef7b 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -46,7 +46,7 @@ Directory picking delegates to the composed `ctx.directoryPicker` backend ([the `host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, and `xdg-open` on desktop Linux). For `.html`, `.htm`, `.xhtml`, and `.svg`, macOS and desktop Linux prefer a named default browser and fall back to that application handoff when none can be named. WSL translates every Linux path through `wslpath -w` and hands the resulting Windows/UNC path to Windows `Invoke-Item`, including browser-renderable documents, instead of assuming a Linux desktop association. The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`. -The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the composer's invocation path: it returns every user-invocable skill with its `modelInvocable` flag, so menus can mark user-only (`disable-model-invocation`) entries whose only entry point this is. `skill.invoke` is the user-explicit loading RPC: it enforces user-invocation policy at this boundary (`skill-not-found` / `skill-not-invocable`), renders the canonical `` body via the shared `renderSkillContent`, appends the optional trailing `text`, injects the whole as a user-role message carrying the `skill-invocation` source, and starts a turn through the same route-served refusal gate as `session.prompt`. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. +The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the composer's menu: it returns every user-invocable skill with its `modelInvocable` flag, so menus can mark user-only (`disable-model-invocation`) entries whose only entry point the slash gesture is. Listing is the skill domain's only RPC — invocation itself is an ordinary `session.prompt` whose whitespace-bounded `/name` tokens `dsh-tool-skill` recognizes at the pre-step boundary and answers with injected `` context, so every front end (web, TUI, ACP, hand-typed text) shares one deterministic path with no dedicated invocation wire. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preference `permission` and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select an arbitrary filesystem target. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. `llm.discoverModels` interrogates a provider endpoint the page is still drafting: `settingsNs` selects the adapter family that knows how to read the listing, and the endpoint, protocol, and key come from the form rather than from storage. It writes nothing — the reply is candidates, and only a later `settings.mutate` decides what a route serves — so its `apiKey` is the third payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`. The host never stores or returns it; like the other two it does ride the client's outgoing envelope, which `subscribeEnvelopes()` observers can see, and redacting that tap is a configuration-plane-wide change rather than this method's to make alone. Every refusal (an unserved namespace, a protocol with no readable listing, an unreachable endpoint, a rejected credential) folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `permission` or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index c988b7540b..de31f65394 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -46,7 +46,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr `host.openPath` 会用操作系统的默认应用打开一个文件系统路径(macOS 为 `open`,Windows 为 `Invoke-Item`,桌面 Linux 为 `xdg-open`)。对于 `.html`、`.htm`、`.xhtml` 与 `.svg`,macOS 和桌面 Linux 会优先使用能够确定的默认浏览器;无法确定时回退到上述应用交接。WSL 会通过 `wslpath -w` 转换每个 Linux 路径,并将所得 Windows/UNC 路径交给 Windows `Invoke-Item`,浏览器可渲染的文档也不例外,而非假定存在 Linux 桌面文件关联。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制。 -`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于 composer 的调用路径:它返回每一个用户可调用的 skill 及其 `modelInvocable` 标志,让菜单能够标出仅限用户(`disable-model-invocation`)的条目——此处是这类条目唯一的入口。`skill.invoke` 是用户显式加载 RPC:它在此边界强制执行用户调用策略(`skill-not-found`/`skill-not-invocable`),经共享的 `renderSkillContent` 渲染规范的 `` 正文,追加可选的尾随 `text`,把整体作为一条携带 `skill-invocation` 来源的 user 角色消息注入,并经由与 `session.prompt` 相同的「路由是否有适配器在服务」拒绝闸门开启一个轮次。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 +`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于 composer 的菜单:它返回每一个用户可调用的 skill 及其 `modelInvocable` 标志,让菜单能够标出仅限用户(`disable-model-invocation`)的条目——斜杠手势是这类条目唯一的入口。列表是 skill 领域唯一的 RPC——调用本身就是一次普通的 `session.prompt`,`dsh-tool-skill` 会在 pre-step 边界识别其中以空白为界的 `/name` token,并以注入的 `` 上下文作答,因此每一种前端(web、TUI、ACP、手动键入的文本)共享同一条确定性路径,没有专设的调用协议。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 `settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `permission` 与产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个、也是最后一个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储或回显。host 从不存储或回传它;与另两者一样,它确实会搭乘客户端的出站信封,`subscribeEnvelopes()` 的观察者能看到——为该 tap 做脱敏是整个配置面的改动,而非本方法一家的事。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据)都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission` 或 `ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。 diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index b001d54625..9a8750c722 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -50,7 +50,6 @@ export interface RpcMethodMap { 'command.list': CommandsApi['list'] 'command.execute': CommandsApi['execute'] 'skill.list': SkillsApi['list'] - 'skill.invoke': SkillsApi['invoke'] 'goal.create': GoalsApi['create'] 'goal.edit': GoalsApi['edit'] 'goal.pause': GoalsApi['pause'] diff --git a/packages/host/apiproxy/src/api/rpc.schema.ts b/packages/host/apiproxy/src/api/rpc.schema.ts index dd3fe7cf57..2733c6e940 100644 --- a/packages/host/apiproxy/src/api/rpc.schema.ts +++ b/packages/host/apiproxy/src/api/rpc.schema.ts @@ -51,8 +51,6 @@ export const rpcErrorSchema: z.ZodType = z.discriminatedUnion('code', z.object({ code: z.literal('steer-unavailable'), message: z.string(), details: z.object({ itemId: z.string() }) }), z.object({ code: z.literal('command-error'), message: z.string(), details: z.object({}) }), z.object({ code: z.literal('unknown-command'), message: z.string(), details: z.object({}) }), - z.object({ code: z.literal('skill-not-found'), message: z.string(), details: z.object({ name: z.string() }) }), - z.object({ code: z.literal('skill-not-invocable'), message: z.string(), details: z.object({ name: z.string() }) }), z.object({ code: z.literal('settings-rejected'), message: z.string(), details: z.object({ ns: z.string() }) }), z.object({ code: z.literal('settings-not-exposed'), message: z.string(), details: z.object({ ns: z.string() }) }), z.object({ code: z.literal('settings-conflict'), message: z.string(), details: z.object({ ns: z.string(), expected: z.number(), actual: z.number() }) }), diff --git a/packages/host/apiproxy/src/api/rpc.ts b/packages/host/apiproxy/src/api/rpc.ts index 7bf41a32e1..54bbb5a8cc 100644 --- a/packages/host/apiproxy/src/api/rpc.ts +++ b/packages/host/apiproxy/src/api/rpc.ts @@ -51,10 +51,6 @@ export interface RpcErrorDetailsMap { 'command-error': {} /** A leading-/ prompt named no registered command; the message names the token. */ 'unknown-command': {} - /** A skill invocation named no skill in the session's workspace (unknown or ill-formed name). */ - 'skill-not-found': { name: string } - /** A skill invocation named a skill whose policy forbids user invocation. */ - 'skill-not-invocable': { name: string } /** * A settings write was refused (schema validation, unknown namespace, * read-only provider, or storage failure); the message is the seam's text. diff --git a/packages/host/apiproxy/src/api/skills.schema.ts b/packages/host/apiproxy/src/api/skills.schema.ts index 1741a93a46..747bf19bad 100644 --- a/packages/host/apiproxy/src/api/skills.schema.ts +++ b/packages/host/apiproxy/src/api/skills.schema.ts @@ -26,18 +26,3 @@ export const skillListRequestSchema = z.object({ export const skillListValueSchema = z.object({ skills: z.array(skillEntrySchema), }) satisfies z.ZodType>> - -/** - * skill.invoke request payload. `text` is the user's trailing message; a - * blank one stays off the wire (the boundary, not client courtesy, refuses it). - */ -export const skillInvokeRequestSchema = z.object({ - sessionId: sessionIdSchema, - name: z.string().min(1), - text: z.string().min(1).optional(), -}) satisfies z.ZodType>> - -/** skill.invoke response value. */ -export const skillInvokeValueSchema = z.object({ - accepted: z.literal(true), -}) satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/skills.ts b/packages/host/apiproxy/src/api/skills.ts index 698a9f0190..3b3e711a93 100644 --- a/packages/host/apiproxy/src/api/skills.ts +++ b/packages/host/apiproxy/src/api/skills.ts @@ -20,22 +20,14 @@ export interface SkillEntry { readonly modelInvocable: boolean } -/** Skill-domain unary methods (the map keys skill.* of RpcMethodMap). */ +/** + * Skill-domain unary methods (the map key skill.* of RpcMethodMap). Listing + * is the domain's only RPC: invocation itself is a plain `session.prompt` + * whose leading `/name` token the host recognizes at the pre-step boundary + * (`dsh-tool-skill` injects the rendered body there), so every client shares + * one deterministic path with no dedicated invocation wire. + */ export interface SkillsApi { /** Lists the user-invocable skill catalog for the session's project. */ list(request: RpcRequest<{ sessionId: SessionId }>): Promise> - - /** - * Injects one user-invocable skill into the addressed agent as a user-role - * message (the canonical `` rendering, with `text` appended - * when present) and starts a turn. The host enforces user-invocation policy - * here — on the discovery summary and again on the loaded definition, so a - * catalog change between the two lookups cannot slip a user-disabled body - * through — a model-only or unknown name is refused regardless of what a - * client menu offered. The carrier's request signal aborts the skill - * lookup and refuses injection once the caller has given up (`cancelled`). - * Session-backed subagents reject with `agent-busy`. - */ - invoke(request: RpcRequest<{ sessionId: SessionId; name: string; text?: string }>, signal: AbortSignal): - Promise> } diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 574206458b..0f54d76dbc 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -39,7 +39,7 @@ import { workspaceRenameValueSchema, } from '../api/workspace.schema.ts' import { commandExecuteValueSchema, commandListValueSchema } from '../api/commands.schema.ts' -import { skillInvokeValueSchema, skillListValueSchema } from '../api/skills.schema.ts' +import { skillListValueSchema } from '../api/skills.schema.ts' import { goalCreateValueSchema, goalEditValueSchema, @@ -118,7 +118,6 @@ export interface IApiClient { } skills: { list(payload: RequestPayload<'skill.list'>, signal?: AbortSignal): Promise>> - invoke(payload: RequestPayload<'skill.invoke'>, signal?: AbortSignal): Promise>> } events: { mux(payload: Parameters[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable> @@ -186,7 +185,6 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType this.callUnary('skill.list', payload, signal), - invoke: (payload, signal) => this.callUnary('skill.invoke', payload, signal), } readonly goals: IApiClient['goals'] = { diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index 8e098680fa..d41b51ad6d 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -41,7 +41,7 @@ import { workspaceRenameRequestSchema, } from '../api/workspace.schema.ts' import { commandExecuteRequestSchema, commandListRequestSchema } from '../api/commands.schema.ts' -import { skillInvokeRequestSchema, skillListRequestSchema } from '../api/skills.schema.ts' +import { skillListRequestSchema } from '../api/skills.schema.ts' import { goalCreateRequestSchema, goalEditRequestSchema, @@ -109,7 +109,6 @@ const UNARY_ROUTES: UnaryRoutes = { 'command.list': { schema: commandListRequestSchema, invoke: (api, r) => api.commands.list(r) }, 'command.execute': { schema: commandExecuteRequestSchema, invoke: (api, r, signal) => api.commands.execute(r, signal) }, 'skill.list': { schema: skillListRequestSchema, invoke: (api, r) => api.skills.list(r) }, - 'skill.invoke': { schema: skillInvokeRequestSchema, invoke: (api, r, signal) => api.skills.invoke(r, signal) }, 'goal.create': { schema: goalCreateRequestSchema, invoke: (api, r) => api.goals.create(r) }, 'goal.edit': { schema: goalEditRequestSchema, invoke: (api, r) => api.goals.edit(r) }, 'goal.pause': { schema: goalPauseRequestSchema, invoke: (api, r) => api.goals.pause(r) }, diff --git a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts index 5b61011370..09526c5a87 100644 --- a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts @@ -269,207 +269,6 @@ describe('skill.list', () => { }) }) -describe('skill.invoke', () => { - /** Provider with one user-only and one model-only skill, both loadable. */ - function registerInvokeSkills(ctx: Context): void { - const summaries = [ - { - name: 'user-only', description: 'User-only', - invocation: { modelInvocable: false, userInvocable: true }, - source: 'custom', provider: 'probe', rank: 0, locator: null, - resourceBase: { kind: 'directory', path: '/proj/.agents/skills/user-only' }, - }, - { - name: 'model-only', description: 'Model-only', - invocation: { modelInvocable: true, userInvocable: false }, - source: 'custom', provider: 'probe', rank: 0, locator: null, - }, - ] as const - ctx.skills.registerProvider(() => ({ - name: 'probe', - list: () => Promise.resolve(summaries.map(summary => ({ ...summary }))), - get: candidate => Promise.resolve({ - ...summaries.find(summary => summary.name === candidate.name)!, - content: 'Follow the probe instructions.', - }), - })) - } - - /** Agent stub whose session carries a project cwd and whose followup records the injected message. */ - function invokableAgent(ctx: Context): { agent: Agent; followup: ReturnType } { - const session = ctx.sessions.create(undefined, { meta: { cwd: '/proj' } }) - const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) - const followup = vi.fn() - const agent = { id: session.id, session, inbox, status: 'idle', ctx, followup } as unknown as Agent - ctx.agents.register(agent) - return { agent, followup } - } - - const live = () => new AbortController().signal - - it('injects a user-invocable skill as a user message with the invocation source', async () => { - const ctx = await harness() - registerInvokeSkills(ctx) - const api = createApiProxy(ctx, DEFAULTS) - const { agent, followup } = invokableAgent(ctx) - const value = expectOk(await api.skills.invoke(request({ - sessionId: agent.id, name: 'user-only', text: 'and check the fixture', - }), live())) - expect(value).toEqual({ accepted: true }) - expect(followup).toHaveBeenCalledTimes(1) - const message = followup.mock.calls[0]?.[0] as UserMessage - expect(message.source).toEqual({ kind: 'skill-invocation', name: 'user-only', args: 'and check the fixture' }) - expect(message.content).toHaveLength(1) - const text = (message.content[0] as { text: string }).text - expect(text).toContain('') - expect(text).toContain('Base directory for this skill: /proj/.agents/skills/user-only') - expect(text).toContain('Follow the probe instructions.') - expect(text.endsWith('\n\nand check the fixture')).toBe(true) - }) - - it('omits args from the source and content when no text rides the invocation', async () => { - const ctx = await harness() - registerInvokeSkills(ctx) - const api = createApiProxy(ctx, DEFAULTS) - const { agent, followup } = invokableAgent(ctx) - expectOk(await api.skills.invoke(request({ sessionId: agent.id, name: 'user-only' }), live())) - const message = followup.mock.calls[0]?.[0] as UserMessage - expect(message.source).toEqual({ kind: 'skill-invocation', name: 'user-only' }) - const text = (message.content[0] as { text: string }).text - expect(text.endsWith('')).toBe(true) - }) - - it('rejects a skill the user may not invoke', async () => { - const ctx = await harness() - registerInvokeSkills(ctx) - const api = createApiProxy(ctx, DEFAULTS) - const { agent, followup } = invokableAgent(ctx) - const error = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'model-only' }), live())) - expect(error.code).toBe('skill-not-invocable') - expect(followup).not.toHaveBeenCalled() - }) - - it('rechecks user policy on the loaded definition (list/get race)', async () => { - const ctx = await harness() - // The provider flips the skill user-invocable in list but user-disabled - // in get — the window a provider change between the two collects opens. - ctx.skills.registerProvider(() => ({ - name: 'flipping', - list: () => Promise.resolve([{ - name: 'flipper', description: 'Race probe', - invocation: { modelInvocable: false, userInvocable: true }, - source: 'custom', provider: 'flipping', rank: 0, locator: null, - }]), - get: () => Promise.resolve({ - name: 'flipper', description: 'Race probe', - invocation: { modelInvocable: false, userInvocable: false }, - source: 'custom', provider: 'flipping', - content: 'Must never inject.', - }), - })) - const api = createApiProxy(ctx, DEFAULTS) - const { agent, followup } = invokableAgent(ctx) - const error = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'flipper' }), live())) - expect(error.code).toBe('skill-not-invocable') - expect(followup).not.toHaveBeenCalled() - }) - - it('reports skill-not-found when the summary wins but the load returns nothing', async () => { - const ctx = await harness() - ctx.skills.registerProvider(() => ({ - name: 'vanishing', - list: () => Promise.resolve([{ - name: 'ghost', description: 'Vanishes on load', - invocation: { modelInvocable: false, userInvocable: true }, - source: 'custom', provider: 'vanishing', rank: 0, locator: null, - }]), - get: () => Promise.resolve(undefined), - })) - const api = createApiProxy(ctx, DEFAULTS) - const { agent, followup } = invokableAgent(ctx) - const error = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'ghost' }), live())) - expect(error.code).toBe('skill-not-found') - expect(followup).not.toHaveBeenCalled() - }) - - it('rejects an unknown or invalid skill name', async () => { - const ctx = await harness() - registerInvokeSkills(ctx) - const api = createApiProxy(ctx, DEFAULTS) - const { agent } = invokableAgent(ctx) - const missing = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'absent-skill' }), live())) - expect(missing.code).toBe('skill-not-found') - const invalid = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'Not A Name' }), live())) - expect(invalid.code).toBe('skill-not-found') - }) - - it('folds a loader failure into a structured internal error', async () => { - const ctx = await harness() - ctx.skills.registerProvider(() => ({ - name: 'exploding', - list: () => Promise.resolve([{ - name: 'grenade', description: 'Loader throws', - invocation: { modelInvocable: false, userInvocable: true }, - source: 'custom', provider: 'exploding', rank: 0, locator: null, - }]), - get: () => Promise.reject(new Error('disk exploded')), - })) - const api = createApiProxy(ctx, DEFAULTS) - const { agent, followup } = invokableAgent(ctx) - const error = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'grenade' }), live())) - expect(error.code).toBe('internal') - expect(error.message).toContain('skill invocation failed') - expect(followup).not.toHaveBeenCalled() - }) - - it('refuses to start a turn the caller already abandoned', async () => { - const ctx = await harness() - registerInvokeSkills(ctx) - const api = createApiProxy(ctx, DEFAULTS) - const { agent, followup } = invokableAgent(ctx) - const abort = new AbortController() - abort.abort() - const error = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'user-only' }), abort.signal)) - expect(error.code).toBe('cancelled') - expect(followup).not.toHaveBeenCalled() - }) - - it('surfaces a followup refusal as agent-busy', async () => { - const ctx = await harness() - registerInvokeSkills(ctx) - const api = createApiProxy(ctx, DEFAULTS) - const { agent, followup } = invokableAgent(ctx) - followup.mockImplementation(() => { throw new Error('inbox closed') }) - const error = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'user-only' }), live())) - expect(error.code).toBe('agent-busy') - }) - - it('refuses a cwd-less session with the skill.list stance', async () => { - const ctx = await harness() - registerInvokeSkills(ctx) - const api = createApiProxy(ctx, DEFAULTS) - const session = ctx.sessions.create(undefined) - const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) - const followup = vi.fn() - ctx.agents.register({ id: session.id, session, inbox, status: 'idle', ctx, followup } as unknown as Agent) - const error = expectErr(await api.skills.invoke(request({ sessionId: session.id, name: 'user-only' }), live())) - expect(error.code).toBe('internal') - expect(error.message).toContain('has no project cwd') - expect(followup).not.toHaveBeenCalled() - }) - - it('fails loud with internal when the skill registry is not mounted', async () => { - const ctx = await harness({ skills: false }) - const api = createApiProxy(ctx, DEFAULTS) - const session = ctx.sessions.create(undefined, { meta: { cwd: '/proj' } }) - const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) - ctx.agents.register({ id: session.id, session, inbox, status: 'idle', ctx, followup: vi.fn() } as unknown as Agent) - const error = expectErr(await api.skills.invoke(request({ sessionId: session.id, name: 'user-only' }), live())) - expect(error.code).toBe('internal') - expect(error.message).toContain('skill registry is absent') - }) -}) - describe('host/commands-changed frame', () => { it('broadcasts on registry change', async () => { const ctx = await harness() diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 0a65c817c6..ebd56ee551 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -86,7 +86,7 @@ function scriptedApi(overrides: { execute: r => ok(r, { matched: false }), ...overrides.commands, }, - skills: { list: r => ok(r, { skills: [] }), invoke: r => ok(r, { accepted: true as const }), ...overrides.skills }, + skills: { list: r => ok(r, { skills: [] }), ...overrides.skills }, goals: { create: err, edit: err, diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 09cabdcc7f..6481d75837 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -198,9 +198,6 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra async list(request) { return { rpcId: request.rpcId, result: { ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits', modelInvocable: true }] } } } }, - async invoke(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } } - }, }, goals: { async create(request) { @@ -385,8 +382,6 @@ describe('unary round trip (handler ⇄ client, no network)', () => { expect(miss.result).toEqual({ ok: true, value: { matched: false } }) const skills = await c.skills.list({ sessionId: 's' as never }) expect(skills.result).toEqual({ ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits', modelInvocable: true }] } }) - const invoked = await c.skills.invoke({ sessionId: 's' as never, name: 'commit-helper', text: 'go' }) - expect(invoked.result).toEqual({ ok: true, value: { accepted: true } }) }) it('lets command.execute finish after the 30-second default unary deadline', async () => { diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 972ccd3621..75b6dff3f7 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -31,7 +31,7 @@ import { commandDescriptorSchema, commandExecuteRequestSchema, commandExecuteValueSchema, commandListRequestSchema, commandListValueSchema, } from '../src/api/commands.schema.ts' -import { skillEntrySchema, skillInvokeRequestSchema, skillInvokeValueSchema, skillListRequestSchema, skillListValueSchema } from '../src/api/skills.schema.ts' +import { skillEntrySchema, skillListRequestSchema, skillListValueSchema } from '../src/api/skills.schema.ts' import { hostFrameSchema, muxFrameSchema, askUserQuestionItemSchema } from '../src/api/events.schema.ts' import { approvalRequestIdSchema, approvalResponsePayloadSchema } from '../src/api/approvals.schema.ts' import { askUserQuestionAnswerSchema, questionResponsePayloadSchema } from '../src/api/questions.schema.ts' @@ -74,8 +74,6 @@ describe('rpcErrorSchema', () => { expect(rpcErrorSchema.parse({ code: 'queue-item-not-found', message: 'm', details: { itemId: 'i' } }).code).toBe('queue-item-not-found') expect(rpcErrorSchema.parse({ code: 'command-error', message: 'm', details: {} }).code).toBe('command-error') expect(rpcErrorSchema.parse({ code: 'unknown-command', message: 'm', details: {} }).code).toBe('unknown-command') - expect(rpcErrorSchema.parse({ code: 'skill-not-found', message: 'm', details: { name: 'n' } }).code).toBe('skill-not-found') - expect(rpcErrorSchema.parse({ code: 'skill-not-invocable', message: 'm', details: { name: 'n' } }).code).toBe('skill-not-invocable') expect(rpcErrorSchema.parse({ code: 'title-invalid', message: 'm', details: { sessionId: 's' } }).code).toBe('title-invalid') expect(rpcErrorSchema.parse({ code: 'internal', message: 'm', details: {} }).code).toBe('internal') }) @@ -83,7 +81,6 @@ describe('rpcErrorSchema', () => { it('rejects a known code with missing details', () => { expect(() => rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: {} })).toThrow() expect(() => rpcErrorSchema.parse({ code: 'title-invalid', message: 'm', details: {} })).toThrow() - expect(() => rpcErrorSchema.parse({ code: 'skill-not-invocable', message: 'm', details: {} })).toThrow() expect(() => rpcErrorSchema.parse({ code: 'command-error', message: 'm' })).toThrow() expect(() => rpcErrorSchema.parse({ code: 'nope', message: 'm', details: {} })).toThrow() }) @@ -408,19 +405,6 @@ describe('skills domain schemas', () => { // modelInvocable is required wire data: an entry without it fails. expect(() => skillEntrySchema.parse({ name: 'n', description: 'd' })).toThrow() }) - - it('validates the invoke request/value pair', () => { - expect(skillInvokeRequestSchema.parse({ sessionId: 's1', name: 'user-only' })) - .toEqual({ sessionId: 's1', name: 'user-only' }) - expect(skillInvokeRequestSchema.parse({ sessionId: 's1', name: 'user-only', text: 'check it' }).text) - .toBe('check it') - expect(() => skillInvokeRequestSchema.parse({ sessionId: 's1', name: '' })).toThrow() - expect(() => skillInvokeRequestSchema.parse({ name: 'user-only' })).toThrow() - // A blank trailing text is refused at the wire boundary, not by client courtesy. - expect(() => skillInvokeRequestSchema.parse({ sessionId: 's1', name: 'user-only', text: '' })).toThrow() - expect(skillInvokeValueSchema.parse({ accepted: true })).toEqual({ accepted: true }) - expect(() => skillInvokeValueSchema.parse({ accepted: false })).toThrow() - }) }) describe('goals domain schemas', () => { From 2b6836a6fe2a29dcaa6ae5d78b715ba911e164f1 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 13:16:27 +0800 Subject: [PATCH 072/109] fix(vendor): widen include's writeTask for exactOptionalPropertyTypes The debounced writer assigns undefined on flush, which a plain optional NodeJS.Timeout rejects under exactOptionalPropertyTypes; the error had been masked by stale build state until a residue cleanup invalidated it. Logged as local modification 14 in the vendor manifest. --- vendor/README.md | 1 + vendor/include/src/index.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/vendor/README.md b/vendor/README.md index 9fa97413c2..4bf1d43f46 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -43,6 +43,7 @@ Keep this log exhaustive — every divergence from upstream must be listed. 11. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`; its built ESM/CJS entries retain explicit `.mjs`/`.cjs` extensions. 12. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. `applyEntryPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes an empty profile root with each bundle's patch layer, the profile's and the home-level `cordis.patch.yml`, and any `--patch` overlays as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/ui/app-boot/tests/config-reload.spec.ts`. 13. **`include/src/index.ts` serialized child-tree mutation and `hmr/src/index.ts` main-watcher initial-scan suppression**: every Include child-tree mutation (initial apply, refresh, `internal/update` patch re-application) runs through one per-Include queue, because the group's transactional `update` is not reentrant — two concurrent applies interleave create and rollback on the same entries and strand the Include fiber without ever settling. The HMR main watcher passes `ignoreInitial: true`: the initial scan re-announced files boot had just consumed, and its `add` for a config file refreshed an Include mid-initial-apply; once serialized, a failing initial apply's rollback disposed HMR, whose teardown drain waited on the queued refresh sitting behind that same apply — a deadlock that exited 13 with no diagnostic. `registerConfig()` keeps its own `ignoreInitial: false` watcher because a user patch layer present at registration must apply once. Covered by the patch-overlay boot-failure built-bin case in `apps/cli/tests/built-bin.e2e.ts`. +14. **`include/src/index.ts` `writeTask` type**: widened the optional `writeTask?: NodeJS.Timeout` property to `NodeJS.Timeout | undefined` — the debounced writer assigns `undefined` on flush, which `exactOptionalPropertyTypes` rejects on a plain optional. Type-only; no behavior change. ## Sync procedure diff --git a/vendor/include/src/index.ts b/vendor/include/src/index.ts index 26b9305c52..5eece997c9 100644 --- a/vendor/include/src/index.ts +++ b/vendor/include/src/index.ts @@ -170,7 +170,7 @@ export class Include extends EntryTree { private readonly: boolean private content?: string private data?: EntryOptions[] - private writeTask?: NodeJS.Timeout + private writeTask?: NodeJS.Timeout | undefined private applyQueue: Promise = Promise.resolve() constructor(ctx: Context, public config: Include.Config) { From 7f138f3f5c2fb4a91871079a10b02c71d699dba9 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 13:52:47 +0800 Subject: [PATCH 073/109] test(examples): re-record catalog stitch sentence in agent-spine inline snapshot --- packages/examples/agent-spine-demo/tests/agent-core.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts index 87fd17c36b..3224487b60 100644 --- a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts +++ b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts @@ -527,6 +527,7 @@ describe('dsh-agent-spine-demo bundle', () => {
If the user names a skill, or the task clearly matches a skill's description, call the \`skill\` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded. + A user may also invoke a skill directly; its block then appears in this conversation. Follow it, and do not call the \`skill\` tool again for that skill. ", "type": "user/message", }, From ed492077a5a1bcbda4d97706f9436e2356637f76 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 14:03:03 +0800 Subject: [PATCH 074/109] test(tool-skill): cover reject passthrough and non-text block scanning --- .../skill/tool-skill/tests/tool-skill.spec.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index fe356da5da..5e0fe58855 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -1017,4 +1017,32 @@ describe('user-explicit invocation injection', () => { (message.source as { kind?: string }).kind === 'skill-invocation') expect(injections).toHaveLength(1) }) + + it('passes a downstream reject through both pre-step listeners untouched', async () => { + const { ctx, agent } = await invokeHarness() + const signal = new AbortController().signal + const decision = await agentEvents(ctx, agent).waterfall( + 'agent/pre-step', + { messages: [gesture('/hidden-demo blocked step')], turn: 1, step: 1, signal }, + () => Promise.resolve({ kind: 'reject' as const }), + ) + expect(decision).toEqual({ kind: 'reject' }) + }) + + it('scans only text blocks of a user message', async () => { + const { ctx, agent } = await invokeHarness() + const mixed = createUserMessage({ + content: [ + { type: 'reasoning', text: '/hidden-demo inside a non-text block' }, + { type: 'text', text: '/shared-skill go' }, + ], + source: { kind: 'user' }, + }) + const decision = await proposeStep(ctx, agent, [mixed]) + if (decision.kind !== 'enter') throw new Error('expected enter') + const invoked = decision.messages + .filter(message => (message.source as { kind?: string }).kind === 'skill-invocation') + .map(message => (message.source as { name: string }).name) + expect(invoked).toEqual(['shared-skill']) + }) }) From 1db327ea6ca2c976d12117cd7d18a19ac12ddc88 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 14:11:17 +0800 Subject: [PATCH 075/109] feat(web): merge compact status and summary cards --- ...ranscript-log-ordered-projection.i18n.yaml | 4 +- ...0-web-transcript-log-ordered-projection.md | 14 +- ...eb-transcript-log-ordered-projection.zh.md | 14 +- apps/web/tests/seeded-history.e2e.ts | 54 ++++--- .../seeded-history/command-row.expected.md | 4 +- .../snapshots/seeded-history/ui.expected.md | 4 +- docs/cordis-catalog/events.md | 2 +- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/commands.i18n.yaml | 4 +- docs/core-data-structures/commands.md | 9 +- docs/core-data-structures/commands.zh.md | 9 +- docs/event-producer-consumer.md | 2 +- docs/persistence-catalog.md | 14 +- packages/client/runtime/README.i18n.yaml | 4 +- packages/client/runtime/README.md | 2 +- packages/client/runtime/README.zh.md | 2 +- .../src/client/sessions/conversation.ts | 13 +- .../src/client/sessions/transcript-adapter.ts | 59 +++++++- .../tests/compact-checkpoint-pin.spec.ts | 5 +- packages/client/runtime/tests/event-script.ts | 15 +- .../runtime/tests/transcript-adapter.spec.ts | 34 +++-- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 3 +- packages/client/ui-conversation/README.zh.md | 3 +- .../src/client/chat/ChatView.tsx | 33 ++++- .../src/client/chat/CompactionCommandCard.tsx | 40 ++++++ .../src/client/chat/CompactionItem.tsx | 24 +++- .../src/client/chat/chat-flow.ts | 65 ++++++++- .../src/client/contract/slots.ts | 12 +- .../ui-conversation/src/client/locales.ts | 4 + .../tests/chat-branch-tails.spec.tsx | 9 +- .../ui-conversation/tests/chat-view.spec.tsx | 132 +++++++++++++++++- .../ui-trajectory/tests/layout.spec.tsx | 5 +- .../compact/command-compact/README.i18n.yaml | 4 +- packages/compact/command-compact/README.md | 2 +- packages/compact/command-compact/README.zh.md | 2 +- packages/compact/command-compact/src/index.ts | 1 + .../tests/command-compact.spec.ts | 31 +++- .../tests/loader-composition.spec.ts | 35 ++++- .../cordis/tool-cordis/src/api-catalog.ts | 2 +- packages/ui/commands/README.i18n.yaml | 4 +- packages/ui/commands/README.md | 4 +- packages/ui/commands/README.zh.md | 4 +- packages/ui/commands/src/index.ts | 32 ++++- packages/ui/commands/src/invariant.ts | 10 ++ packages/ui/commands/tests/commands.spec.ts | 22 +++ packages/ui/commands/tests/invariant.spec.ts | 89 ++++++++++++ 47 files changed, 725 insertions(+), 121 deletions(-) create mode 100644 packages/client/ui-conversation/src/client/chat/CompactionCommandCard.tsx create mode 100644 packages/ui/commands/tests/invariant.spec.ts diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml index 26108d6850..cede7ea5d9 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.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-30-web-transcript-log-ordered-projection.md -2026-07-30-web-transcript-log-ordered-projection.md: 558d60cf6f6638c3e776396dd754d31b95819b28 -2026-07-30-web-transcript-log-ordered-projection.zh.md: 2eb216fd1970700fb54a2aa17ec5ce515869e5b0 +2026-07-30-web-transcript-log-ordered-projection.md: 3b7aaeb1178ff79e38a1b9646a9dc78efaeae48b +2026-07-30-web-transcript-log-ordered-projection.zh.md: acd198b6d3f3c5d57e233e09ee66f5d151e4f8f1 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md index 558d60cf6f..3b7aaeb117 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md @@ -18,9 +18,11 @@ Node order is seq-monotonic by construction, and three things follow. The log-on `foldDegraded` is gone from `ConversationSnapshot`, and with it the padding sentinels, the `baseSeq` arithmetic they needed, and `degradedSeqs()`. They existed only to satisfy the core fold's `seq === index` assertion and to survive its throw; the fold they describe is no longer run. Deleting the flag is part of the fix, not cleanup after it — `degradedSeqs()` was already almost the log-ordered projection, reached after a thrown error instead of intended. -The marker's summary text comes from the checkpoint's own `compact/summary` provenance, never from the framed checkpoint payload, which is an instruction envelope written for the model. A window cut that left the provenance outside makes the row non-expandable rather than empty, the same soft-fall as a call-less tool result, and a later page supplying the provenance resolves the text. +The marker's summary text, replaced-item count, and estimated shadowed-token count come from the checkpoint's own `compact/summary` provenance, never from the framed checkpoint payload, which is an instruction envelope written for the model. A window cut that left the provenance outside makes those fields unavailable, the same soft-fall as a call-less tool result, and a later page supplying the provenance resolves them. -No persisted event, RPC envelope, compaction transaction, or model-visible surface changed, and no migration is required. +The [manual compaction command](../feature/2026-07-30-queued-manual-compaction.md) returns the summary event's seq as the successful `CommandResult.sourceEventSeq`, and `command/done` persists that optional reference. Chat pairs only a successful named `/compact` command whose reference equals exactly one loaded `CompactionSummaryNode.summaryEventSeq`. The running command first renders `compact · Compacting context…`; after the checkpoint lands, the same React key renders one collapsed `compact` disclosure at the checkpoint's flow position with the count and token estimate. Input rejection, no compactable history, cancellation, and failure remain generic command rows with complete handler-authored text. Automatic compaction has no command reference and keeps the standalone context-compacted marker. + +The explicit event reference matters because manual compaction permits durable context injection while its asynchronous summary is running: command and checkpoint rows are not guaranteed to be adjacent. The command lifecycle event gains one optional field, but the compaction transaction, RPC envelope, and model-visible surface do not change; pre-release persisted logs without the field keep the former two-row soft-fall and require no migration. ## Recognizing a checkpoint: one declaration, pinned at compile time @@ -57,6 +59,10 @@ The unmerged manual-compaction-queueing branch fixes the same interleaving bug b **Keep `foldDegraded` as a defensive flag.** Rejected: it described a specific failure of a fold that no longer runs. A flag no consumer can act on, reachable only through a `console.error`, is a false contract. +**Pair the nearest `/compact` row with the next checkpoint.** Rejected: context injection may land between them, and concurrent or malformed lifecycle records must degrade without stealing another checkpoint. The command result instead names the authoritative summary event, and ambiguous references pair nothing. + +**Parse the English settlement text for item and token counts.** Rejected: handler copy is presentation text, not a stable data contract. The marker reads the structured `compact/summary` payload already owning both values. + ## Consequences Compaction no longer erases web history; a session compacted several times shows one marker per landed compaction, in log order, and the same window renders identically live and after a cold resume. The pagination hole is closed by construction rather than defended against, and `ConversationSnapshot` loses a published field, which touched thirteen files. @@ -65,8 +71,8 @@ Compaction no longer erases web history; a session compacted several times shows The performance contract is unchanged and now simpler to state: one append materializes one node, an event that changes no node keeps the previous array reference — so a chunk storm costs nothing and `nodes()` is not even recomputed — and unchanged nodes keep their object identity. The window still grows with session length rather than with the surface, which is the trade the fix exists to make; a compaction used to bound the projection for exactly the long sessions compaction serves. -The web e2e scenario now seeds a real compaction transaction over its recorded turn, so the aria golden pins both halves of the fix through the real host and a real browser: the recorded prompt and full tool output are still on screen, and one marker sits after them. The seed recording itself is untouched and stays model-authentic — replay derives the compacted turn from the recording's own surface. +The web e2e scenario now seeds a real manual command lifecycle around a compaction transaction over its recorded turn, so the aria golden pins the complete behavior through the real host and a real browser: the recorded prompt and full tool output are still on screen, exactly one `compact` row reports scale after them, and its disclosure opens the exact summary. The seed recording itself is untouched and stays model-authentic — replay derives the manual compaction from the recording's own surface. ## Deferred -The terminal's [archived compaction progress decision](../../archived/feature/2026-07-30-compaction-progress-visibility.md) uses the live standalone bracket to drive a one-cell indicator and does not change this browser projection. The marker still carries no **scale**: the checkpoint's `sourceEventSeqs` hold the shadowed count, so a separately justified count or range can be added without coupling it to progress. +The terminal's [archived compaction progress decision](../../archived/feature/2026-07-30-compaction-progress-visibility.md) uses the live standalone bracket to drive a one-cell indicator and does not change this browser projection. diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md index 2eb216fd19..acd198b6d3 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md @@ -18,9 +18,11 @@ surface 顺序还让另外两个问题成为结构性的。一次替换之后它 `foldDegraded` 从 `ConversationSnapshot` 消失,随之消失的是哨兵填充、它们所需的 `baseSeq` 算术,以及 `degradedSeqs()`。它们的存在只为满足核心 fold 的 `seq === index` 断言并在其抛错时存活;它们所描述的 fold 已不再运行。删除该标志是修复的一部分,而非修复之后的清理——`degradedSeqs()` 本身已几乎就是按日志顺序的投影,只是作为抛错后的落点而非本意到达。 -标记的摘要文本来自检查点自己的 `compact/summary` 溯源,绝不取自成框的检查点载荷——那是为模型撰写的指令信封。窗口切分把溯源留在窗口外时该行不可展开而非空白,与无调用的工具结果同一种软退让;后续补上溯源的分页会解析出文本。 +标记的摘要文本、被替换条目数量和估算的被遮蔽 token 数量都来自检查点自己的 `compact/summary` 溯源,绝不取自成框的检查点载荷——那是为模型撰写的指令信封。窗口切分把溯源留在窗口外时这些字段不可用,与无调用的工具结果同一种软退让;后续补上溯源的分页会解析出它们。 -没有任何持久化事件、RPC 信封、压缩事务或模型可见 surface 发生变化,也不需要迁移。 +[手动压缩命令](../feature/2026-07-30-queued-manual-compaction.md)会把摘要事件的 seq 作为成功结果的 `CommandResult.sourceEventSeq` 返回,`command/done` 则持久化这项可选引用。Chat 只会配对成功且名称为 `/compact`、其引用恰好等于唯一一个已加载 `CompactionSummaryNode.summaryEventSeq` 的命令。运行中的命令先渲染为 `compact · Compacting context…`;检查点落地后,同一个 React key 会在检查点的消息流位置渲染一条收起的 `compact` 展开项,并显示条目数量和 token 估算值。输入被拒绝、没有可压缩历史、取消和失败时仍使用通用命令行,并保留处理器撰写的完整文本。自动压缩没有命令引用,继续使用独立的上下文已压缩标记。 + +显式事件引用之所以重要,是因为手动压缩允许在异步摘要运行期间注入持久上下文:命令行与检查点行不保证相邻。命令生命周期事件增加一个可选字段,但压缩事务、RPC 信封和模型可见 surface 均不变化;不含该字段的预发布持久日志继续采用原先的两行软退让,无须迁移。 ## 识别检查点:同一份声明,在编译期钉住 @@ -57,6 +59,10 @@ const COMPACT_PLUGIN: typeof COMPACT_CHECKPOINT_SOURCE.plugin = 'compact' **把 `foldDegraded` 留作一个防御性标志。** 已拒绝:它描述的是一个已不再运行的 fold 的特定失败。一个消费方无法据以行动、只能通过 `console.error` 到达的标志,是一份虚假契约。 +**把最近的 `/compact` 行与下一个检查点配对。** 已拒绝:两者之间可能落入上下文注入,并发或格式异常的生命周期记录也必须降级而不误取其他检查点。命令结果则指明权威摘要事件;引用存在歧义时不配对任何内容。 + +**解析英文结算文本中的条目数量和 token 数量。** 已拒绝:处理器文案是呈现文本,而非稳定的数据契约。标记读取本已持有这两个值的结构化 `compact/summary` 载荷。 + ## Consequences 压缩不再抹掉 Web 历史;一个被压缩多次的会话按日志顺序显示每次落地压缩一个标记,而同一窗口在实时与冷恢复之后渲染完全相同。分页缺口是被构造性闭合而非被防御,`ConversationSnapshot` 少了一个已发布字段,这触及十三个文件。 @@ -65,8 +71,8 @@ const COMPACT_PLUGIN: typeof COMPACT_CHECKPOINT_SOURCE.plugin = 'compact' 性能契约未变,且现在更易表述:一次追加物化一个节点,不改变任何节点的事件保持上一次的数组引用——因此分片风暴零成本、`nodes()` 甚至不会重算——未变化的节点保持其对象标识。窗口仍随会话长度而非随 surface 增长,这正是本修复存在所要做的交换;一次压缩过去恰好为压缩所服务的长会话限制了投影规模。 -Web e2e 场景现在在它录制的那一轮之上播种一次真实的压缩事务,因此 aria 基准经真实宿主与真实浏览器钉住修复的两半:录制的提问与完整工具输出仍在屏幕上,其后坐着一个标记。录制本身未被触碰、保持模型真实——回放从录制自身的 surface 派生出被压缩的那一轮。 +Web e2e 场景现在围绕它录制的那一轮上的压缩事务播种一次真实的手动命令生命周期,因此 aria 基准经真实宿主与真实浏览器钉住完整行为:录制的提问与完整工具输出仍在屏幕上,其后恰好一条 `compact` 行报告规模,展开后会显示确切摘要。录制本身未被触碰、保持模型真实——回放从录制自身的 surface 派生出手动压缩。 ## Deferred -终端的[已归档压缩进度决策](../../archived/feature/2026-07-30-compaction-progress-visibility.md)使用实时独立标记对驱动单格指示器,并不改变此浏览器投影。标记仍不携带**规模**信息:检查点的 `sourceEventSeqs` 保存被遮蔽的数量,因此可以另行论证后添加计数或区间,而无须将其与进度耦合。 +终端的[已归档压缩进度决策](../../archived/feature/2026-07-30-compaction-progress-visibility.md)使用实时独立标记对驱动单格指示器,并不改变此浏览器投影。 diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 799503ef1c..fac240fbe1 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -4,8 +4,9 @@ // history RPC, history-page tool views, and the client's log-ordered transcript // events — with ZERO model calls in replay (no replay fixture; a stray stream // fails loud on the open llm seam). The cold session also carries the one -// keyless command-row surface: an Access-chip pick runs `/permission` on the -// host, so the settled row's copy has a golden here. The seed is a recorded +// keyless command-row surfaces: the seeded manual `/compact` lifecycle folds +// into its checkpoint, while an Access-chip pick later runs `/permission` on +// the host. The seed is a recorded // fixture under the // same record discipline as every other: DSH_SNAPSHOT=record drives the turn // live through the composer (real read tool against seeded workspace files) @@ -39,18 +40,18 @@ const SEED_ID = 'seeded-history-web-e2e' const PROMPT = 'Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop.' /** - * Append a complete, valid compaction transaction over the recorded turn's own - * surface. The recording stays model-authentic and reusable; replay adds this - * deterministic condition before seeding it cold, so the scenario pins the bug - * this change fixes — a landed compaction must not erase history the reader - * already saw — through the real host and the real browser. + * Append a complete manual `/compact` lifecycle and valid compaction transaction + * over the recorded turn's own surface. The recording stays model-authentic and + * reusable; replay adds this deterministic condition before seeding it cold, so + * the scenario pins both the log-preserving marker and its single-card command + * presentation through the real host and browser. * @param raw - the seed fixture text, already realized (placeholder-free) so * the shadow price below is computed from the exact strings the host folds. * @param meter - the composed token meter; the appended `compact/summary`'s * shadow price must be the exact heuristic price of the shadowed nodes, the * way compact-basic derives it, because the token-meter projections subtract * it verbatim. - * @returns the fixture with a compacted turn appended. + * @returns the fixture with a manual compaction lifecycle appended. */ function withCompaction(raw: string, meter: TokenMeterService): string { const lines = raw.trimEnd().split('\n') @@ -73,14 +74,10 @@ function withCompaction(raw: string, meter: TokenMeterService): string { if (first === undefined || last === undefined || tail === undefined) { throw new Error('seeded-history compaction requires a non-empty closed surface') } - // The transaction opens the turn after the recording's last closed one; read - // it from the fixture so a re-recording with a different turn count stays - // valid instead of appending a duplicate turn number. const lastTurn = events.filter(event => event.type === 'turn/end').at(-1)?.data?.turn if (typeof lastTurn !== 'number') { throw new Error('seeded-history compaction requires a recording ending on a closed turn') } - const turn = lastTurn + 1 let seq = tail.seq + 1 let time = tail.time + 1 /** @@ -93,8 +90,12 @@ function withCompaction(raw: string, meter: TokenMeterService): string { lines.push(JSON.stringify({ ...event, seq: taken, time: time++ })) return taken } - at({ type: 'turn/start', data: { turn } }) - const startSeq = at({ type: 'compact/start', data: { turn } }) + const commandId = 'cmd-seeded-manual-compact' + at({ + type: 'command/run', + data: { commandId, name: 'compact', args: '', source: { kind: 'user' } }, + }) + const startSeq = at({ type: 'compact/start', data: { turn: null } }) // Load-bearing exactness: the projections subtract this count verbatim, so // it must equal what the host's fold prices for these nodes. The estimator // prices message CONTENT only, so a minimal wrapper per storage shape is @@ -146,8 +147,21 @@ function withCompaction(raw: string, meter: TokenMeterService): string { surfaceOp: { op: 'replace', start: first, end: last }, sourceEventSeqs: [startSeq, summarySeq, ...surfaceSeqs], }) - at({ type: 'compact/end', data: { turn } }) - at({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } }) + at({ type: 'compact/end', data: { turn: null } }) + at({ + type: 'command/done', + data: { + commandId, + kind: 'success', + text: `Compacted ${surfaceSeqs.length} history items (~${shadowedTokenCount} tokens).`, + sourceEventSeq: summarySeq, + }, + }) + // The persistence seed helper requires a terminal turn/end. Keep the manual + // command standalone, then add a closed zero-step fixture boundary after it. + const closureTurn = lastTurn + 1 + at({ type: 'turn/start', data: { turn: closureTurn } }) + at({ type: 'turn/end', data: { turn: closureTurn, reason: { kind: 'completed' } } }) return `${lines.join('\n')}\n` } @@ -239,7 +253,11 @@ describe('web e2e: seeded history renders through cold resume', () => { await sessionRow.click() // Settled barrier for history: the recorded final assistant text renders. await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBe(1) - await expect.poll(() => page.getByText('Context compacted', { exact: true }).count(), { timeout: 10_000 }).toBe(1) + await expect.poll(() => page.getByText('compact', { exact: true }).count(), { timeout: 10_000 }).toBe(1) + await expect.poll(() => page.getByText(/^Compacted \d+ history items \(~\d+ tokens\)$/).count(), { + timeout: 10_000, + }).toBe(1) + expect(await page.getByText('Context compacted', { exact: true }).count()).toBe(0) // Tool cards render from logged tool/call + tool/result alone (views are // host-recomputed per page; the generic card is the documented default). const toolRows = page.locator('[data-variant], [data-sample]') @@ -363,7 +381,7 @@ describe('web e2e: seeded history renders through cold resume', () => { it.skipIf(MODE === 'record')('expands the cold-resumed compact summary', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-compaction')) - const marker = page.getByRole('button', { name: /Context compacted/ }) + const marker = page.getByRole('button', { name: /compact Compacted \d+ history items/ }) await marker.waitFor({ timeout: 10_000 }) expect(await marker.getAttribute('aria-expanded')).toBe('false') await marker.click() diff --git a/apps/web/tests/snapshots/seeded-history/command-row.expected.md b/apps/web/tests/snapshots/seeded-history/command-row.expected.md index fe58587913..21b9cefeec 100644 --- a/apps/web/tests/snapshots/seeded-history/command-row.expected.md +++ b/apps/web/tests/snapshots/seeded-history/command-row.expected.md @@ -31,9 +31,9 @@ - button "Branch into a new conversation": - img - text: 7/25 {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s -- button "Context compacted View compaction summary": +- button "compact Compacted 5 history items (~247 tokens)": - img - - text: Context compacted View compaction summary + - text: compact Compacted 5 history items (~247 tokens) - button "Context injection AGENTS.md": - img - img diff --git a/apps/web/tests/snapshots/seeded-history/ui.expected.md b/apps/web/tests/snapshots/seeded-history/ui.expected.md index ce2921eac2..2502d90f90 100644 --- a/apps/web/tests/snapshots/seeded-history/ui.expected.md +++ b/apps/web/tests/snapshots/seeded-history/ui.expected.md @@ -31,9 +31,9 @@ - button "Branch into a new conversation": - img - text: 7/25 {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s -- button "Context compacted View compaction summary": +- button "compact Compacted 5 history items (~247 tokens)": - img - - text: Context compacted View compaction summary + - text: compact Compacted 5 history items (~247 tokens) - button "Context injection AGENTS.md": - img - img diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 4ad9797262..7fd91874d3 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -343,7 +343,7 @@ A command was registered or unregistered. This is an unfiltered registry notific 'commands/change'(): void ``` -Source: [`packages/ui/commands/src/index.ts:161`](../../packages/ui/commands/src/index.ts) +Source: [`packages/ui/commands/src/index.ts:172`](../../packages/ui/commands/src/index.ts) ## `credentials/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 4a73dc06ad..6e75b2a345 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -451,7 +451,7 @@ async execute( agent: Agent, line: string, signal: AbortSignal, ): Promise Number.isSafeInteger(seq) && (seq as number) >= 0) + ? shadowedSeqs.length + : null, + shadowedTokenCount: Number.isSafeInteger(tokenCount) && (tokenCount as number) >= 0 + ? tokenCount as number + : null, + } +} + /** * One landed checkpoint -> the human-facing compaction marker. The summary text * comes from the checkpoint's own provenance (`sourceEventSeqs` names the @@ -170,13 +193,28 @@ function materializeCompaction( ): CompactionSummaryNode { const sources = (checkpoint as SessionEvent & { sourceEventSeqs?: number[] }).sourceEventSeqs let summary: string | null = null + let summaryEventSeq: number | null = null + let shadowedItemCount: number | null = null + let shadowedTokenCount: number | null = null for (const seq of sources ?? []) { const candidate = eventIndex.get(seq) if (candidate === undefined || (candidate.type as string) !== 'compact/summary') continue - summary = compactSummaryText(candidate) + const details = compactSummaryDetails(candidate) + summary = details.summary + summaryEventSeq = candidate.seq + shadowedItemCount = details.shadowedItemCount + shadowedTokenCount = details.shadowedTokenCount break } - return { kind: 'compaction', seq: checkpoint.seq, time: checkpoint.time, summary } + return { + kind: 'compaction', + seq: checkpoint.seq, + time: checkpoint.time, + summary, + summaryEventSeq, + shadowedItemCount, + shadowedTokenCount, + } } /** Log-ordered human transcript over a paged raw event window (never consults surface order). */ @@ -321,9 +359,22 @@ export class TranscriptAdapter { return true } if ((event.type as string) !== 'command/done') return false - const data = event.data as unknown as { commandId: CommandId; kind: 'success' | 'error'; text?: string } + const data = event.data as unknown as { + commandId: CommandId + kind: 'success' | 'error' + text?: string + sourceEventSeq?: number + } const run = this.commandIdx.get(data.commandId) - const outcome = { kind: data.kind, ...data.text === undefined ? {} : { text: data.text } } + const sourceEventSeq = data.kind === 'success' + && Number.isSafeInteger(data.sourceEventSeq) && (data.sourceEventSeq as number) >= 0 + ? data.sourceEventSeq as number + : undefined + const outcome = { + kind: data.kind, + ...data.text === undefined ? {} : { text: data.text }, + ...sourceEventSeq === undefined ? {} : { sourceEventSeq }, + } if (run === undefined) { // Cross-window cut: the run page fell out of the window — build the // node from the done alone (same soft-fall as a call-less tool result). diff --git a/packages/client/runtime/tests/compact-checkpoint-pin.spec.ts b/packages/client/runtime/tests/compact-checkpoint-pin.spec.ts index ddc6c8adc5..aed658af51 100644 --- a/packages/client/runtime/tests/compact-checkpoint-pin.spec.ts +++ b/packages/client/runtime/tests/compact-checkpoint-pin.spec.ts @@ -36,7 +36,10 @@ describe('compaction checkpoint recognition', () => { it('recognizes a checkpoint carrying the seam-canonical source', () => { const adapter = new TranscriptAdapter() adapter.reset([canonicalCheckpoint(1)]) - expect(adapter.nodes()).toEqual([{ kind: 'compaction', seq: 1, time: 1_700_000_000_001, summary: null }]) + expect(adapter.nodes()).toEqual([{ + kind: 'compaction', seq: 1, time: 1_700_000_000_001, summary: null, + summaryEventSeq: null, shadowedItemCount: null, shadowedTokenCount: null, + }]) }) it("agrees with the seam's own predicate on the source it recognizes", () => { diff --git a/packages/client/runtime/tests/event-script.ts b/packages/client/runtime/tests/event-script.ts index 51e982e8f4..bc3c10e762 100644 --- a/packages/client/runtime/tests/event-script.ts +++ b/packages/client/runtime/tests/event-script.ts @@ -92,8 +92,19 @@ export const ev = { at(seq, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }), commandRunWithoutInput: (seq: number, commandId: string, name: string): SessionEvent => at(seq, { type: 'command/run', data: { commandId, name, source: { kind: 'user' } } }), - commandDone: (seq: number, commandId: string, kind: 'success' | 'error' = 'success', text?: string): SessionEvent => - at(seq, { type: 'command/done', data: { commandId, kind, ...text === undefined ? {} : { text } } }), + commandDone: ( + seq: number, + commandId: string, + kind: 'success' | 'error' = 'success', + text?: string, + sourceEventSeq?: number, + ): SessionEvent => + at(seq, { type: 'command/done', data: { + commandId, + kind, + ...text === undefined ? {} : { text }, + ...sourceEventSeq === undefined ? {} : { sourceEventSeq }, + } }), /** A compaction's log-only `compact/summary` provenance record. */ compactSummary: (seq: number, summary: string, start: number, end: number): SessionEvent => at(seq, { type: 'compact/summary', data: { diff --git a/packages/client/runtime/tests/transcript-adapter.spec.ts b/packages/client/runtime/tests/transcript-adapter.spec.ts index e4ef3b0e1a..626c7792f7 100644 --- a/packages/client/runtime/tests/transcript-adapter.spec.ts +++ b/packages/client/runtime/tests/transcript-adapter.spec.ts @@ -223,8 +223,14 @@ describe('TranscriptAdapter', () => { checkpoint(5, 4, { start: 2, end: 3, sourceEventSeqs: [4, 2, 3] }), ]) expect(adapter.nodes().filter(n => n.kind === 'compaction')).toEqual([ - { kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: 'first' }, - { kind: 'compaction', seq: 5, time: 1_700_000_000_005, summary: 'second' }, + { + kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: 'first', + summaryEventSeq: 1, shadowedItemCount: 2, shadowedTokenCount: 100, + }, + { + kind: 'compaction', seq: 5, time: 1_700_000_000_005, summary: 'second', + summaryEventSeq: 4, shadowedItemCount: 2, shadowedTokenCount: 100, + }, ]) }) @@ -296,7 +302,7 @@ describe('TranscriptAdapter', () => { ...(summary === undefined ? [] : [summary]), checkpoint(2, 1, { start: 0, end: 0, sourceEventSeqs: [1, 0] }), ]) - expect(adapter.nodes()).toEqual([ + expect(adapter.nodes()).toMatchObject([ { kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: null }, ]) }) @@ -310,7 +316,10 @@ describe('TranscriptAdapter', () => { checkpoint(2, 1, { start: 0, end: 0, sourceEventSeqs: [1, 0] }), ]) expect(adapter.nodes()).toEqual([ - { kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: '可用摘要' }, + { + kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: '可用摘要', + summaryEventSeq: 1, shadowedItemCount: 2, shadowedTokenCount: 100, + }, ]) }) @@ -324,7 +333,10 @@ describe('TranscriptAdapter', () => { source: { kind: 'plugin', plugin: 'compact' }, }), })]) - expect(adapter.nodes()).toEqual([{ kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: null }]) + expect(adapter.nodes()).toEqual([{ + kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: null, + summaryEventSeq: null, shadowedItemCount: null, shadowedTokenCount: null, + }]) }) it('skips a non-summary provenance seq before reaching the real one', () => { @@ -468,20 +480,22 @@ describe('TranscriptAdapter', () => { expect(adapter.nodes().map(n => n.kind)).toEqual(['user', 'command']) }) - it('renders the /compact row alongside the marker its own command produced', () => { - // The row that reports the compaction is a command node; dropping command - // folding would delete it together with every other slash-command row. + it('preserves the domain-event link for the UI to fold a /compact row into its marker', () => { const adapter = new TranscriptAdapter() adapter.reset([ ev.user(0, '压缩前的问题'), ev.commandRun(1, 'cmd-compact', 'compact'), compactSummary(2, [{ type: 'text', text: '手动压缩摘要' }]), checkpoint(3, 2, { start: 0, end: 0, sourceEventSeqs: [2, 0] }), - ev.commandDone(4, 'cmd-compact', 'success', '已压缩'), + ev.commandDone(4, 'cmd-compact', 'success', '已压缩', 2), ]) const nodes = adapter.nodes() expect(nodes.map(n => [n.kind, n.seq])).toEqual([['user', 0], ['command', 1], ['compaction', 3]]) - expect(nodes[1]).toMatchObject({ name: 'compact', outcome: { kind: 'success', text: '已压缩' } }) + expect(nodes[1]).toMatchObject({ + name: 'compact', + outcome: { kind: 'success', text: '已压缩', sourceEventSeq: 2 }, + }) + expect(nodes[2]).toMatchObject({ kind: 'compaction', summaryEventSeq: 2 }) }) }) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index b6bf25d410..ca3289ba55 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/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-conversation/README.md -README.md: 0cf50146cc44ef0d6cc060a4c97b3d1ff454f013 -README.zh.md: 8bfb96bb9326d8fcadc3c357b6abaad88c92bd17 +README.md: 5b37065097ef60c2edf14725f4e1e1c6a52c4366 +README.zh.md: 4ec26155a124497db0fc7f351d20ecb451a18763 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 0cf50146cc..5b37065097 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation, an animated left-to-right gradient `Deep diving...` turn status, per-tool row slot with a bash sample registrant and the todo row), composer dock (session stats sticky with the input), input dock (hairline-separated queue rows plus the todo plan strip), minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares). -Compaction renders as one collapsed row at the checkpoint's flow position without replacing the transcript above it. The disclosure renders the checkpoint's `compact/summary` provenance; when that event is outside the loaded window, the row remains visible but non-expandable. The framed checkpoint payload is model-facing and never renders. +Compaction renders as one collapsed row at the checkpoint's flow position without replacing the transcript above it. Automatic compaction uses the context-compacted title. Manual `/compact` starts as a running `compact` row; on successful settlement its explicit summary-event reference folds that command into the checkpoint row under the same React key, showing the replaced-item and estimated-token counts and disclosing the summary on click. Input rejection, no compactable history, cancellation, and failure retain the generic command row and its handler-authored text. Pairing never depends on adjacency because durable context may be injected while compaction is running. The framed checkpoint payload is model-facing and never renders; when summary provenance is outside the loaded window, the checkpoint remains visible but non-expandable. The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. The root always owns the same scrollport and Hero/composer subtree; separate strict-session header and body outlets fill their regions when the first Session arrives, so the Workspace picker, scroll body, composer seat, and textarea retain their React and DOM identity. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header shows only the current session title and view tabs as ordinary column chrome; fork lineage remains session data and is not projected into the header. Beneath it the scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). That scrollport reserves its scrollbar gutter unconditionally, and a view opting into a composer overlay leaves it a scroll container, so the input card keeps one horizontal position whether or not the transcript scrolls and whichever view tab is shown ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host. @@ -64,7 +64,6 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **Compaction markers show no scale** — the row does not yet report how many messages or which range the checkpoint replaced. - **Stats-line durations and speeds cover the in-window flow only** — LLM and tool wall times plus the TTFT and throughput averages fold the snapshot's assistant `timing` and tool call/result pairs, so nodes outside the loaded event window (older history) are not counted. - **The details panel has no entry point** — `ChatViewInjected.openDetails` is implemented but uncalled, so the raw selected-call display is unreachable in the assembled application. There is no Input/Output/Metadata switch, Prev/Next stepping, or trajectory deep link. - **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / clock / branch) ships under the last content-text assistant of each turn that has ended; mid-turn narration, Think-only nodes, and every node of a turn still producing steps stay chrome-free. Branch stays disabled unless that message is also the last transcript node of a completed turn; when enabled, it forks through that turn, increments the inherited title on the client, and opens the child. A fork or rename failure leaves the source selected ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md)). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 8bfb96bb93..4ec26155a1 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -4,7 +4,7 @@ 会话领域:骨架(标题栏/标签页/编辑器/空状态)、聊天视图(分组步骤摘要流、流式尾部隔离、带从左到右动态渐变的 `Deep diving...` 轮次状态、逐工具行 slot 及一个 bash 示例注册方与 todo 行)、编辑器 dock(与输入区一同 sticky 的会话统计行)、输入区 dock(带发丝分界线的队列行加 todo 计划条)、最小详情面板、按 scope 寻址的 ConversationService。契约:api-contracts v3 §7 加 slot 终端设计(store seat/props share)。 -压缩(compaction)在检查点自身的消息流位置渲染为一行折叠标记,不替换其上方的 transcript(文本记录)。展开内容来自检查点溯源的 `compact/summary`;该事件位于已加载窗口之外时,标记仍然可见但不可展开。面向模型的带框检查点载荷绝不渲染。 +压缩(compaction)在检查点自身的消息流位置渲染为一行折叠标记,不替换其上方的 transcript(文本记录)。自动压缩使用「上下文已压缩」标题。手动 `/compact` 开始时显示为运行中的 `compact` 行;成功结算后,其显式摘要事件引用会在保持同一 React key 的前提下把该命令折叠进检查点行,显示被替换条目数量和估算 token 数量,并可点击展开摘要。输入被拒绝、没有可压缩历史、取消和失败时仍使用通用命令行及处理器撰写的文本。配对绝不依赖相邻关系,因为压缩运行期间可能注入持久上下文。面向模型的带框检查点载荷绝不渲染;摘要溯源位于已加载窗口之外时,检查点仍然可见但不可展开。 常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。根组件始终拥有同一个滚动容器与 Hero/编辑器子树;首个会话到达时,彼此独立的严格会话页头和主体 outlet 只填入各自区域,因此 Workspace 选择器、滚动主体、编辑器 seat 与 textarea 都保留原有 React 和 DOM identity。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段,会话标题栏作为普通列 chrome,仅显示当前会话标题和视图标签;fork 谱系仍保留为会话数据,不投影到标题栏。其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。该滚动容器无条件预留自己的滚动条槽,选用编辑器 overlay 的视图也仍把它保留为滚动容器,因此无论对话记录是否滚动、无论展示哪个视图标签,输入卡片都保持同一个横向位置([决策](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md))。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。 @@ -64,7 +64,6 @@ Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。Qu ## 已知限制与暂缓事项 -- **压缩标记不显示规模**:该行尚不报告检查点替换了多少条消息或哪段范围。 - **统计行的耗时与速率只覆盖窗口内消息流**:LLM 与工具墙钟时间以及 TTFT 与吞吐平均值由快照的 assistant `timing` 与工具 call/result 配对折算,落在已加载事件窗口之外的节点(更早的历史)不计入。 - **详情面板没有入口**:`ChatViewInjected.openDetails` 虽已实现却无人调用,因此以原始形式显示已选择调用的那部分在组装后的应用中不可达。没有 Input/Output/Metadata 切换、Prev/Next 步进,也没有 trajectory 深链接。 - **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/时钟/分支)只挂在每个已结束轮次中最后一条带 text 内容的 assistant 下;轮次中间的叙述、纯 Think 节点,以及仍在产出步骤的轮次里的所有节点都不带 chrome。除非该消息同时也是已完成轮次的最后一个 transcript 节点,否则分支保持禁用;启用后,它会 fork 到该轮次末尾,在 client 端递增继承标题并打开子会话。fork 或改名失败时源会话保持选中([决策](../../../.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md))。 diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index b0907f5a80..b15bc20cdb 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -32,6 +32,7 @@ import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps } from '../contract/slots.ts' import { assistantActionsSeqs, assistantBranchSeqs, deriveChatFlow, runningTurnStartTime, type ChatFlowItem } from './chat-flow.ts' import { AssistantMarkdown } from './AssistantMarkdown.tsx' +import { CompactionCommandCard } from './CompactionCommandCard.tsx' import { GenericCommandCard } from './GenericCommandCard.tsx' import { GenericToolCard } from './GenericToolCard.tsx' import { MessageItem, PendingSteeringBubble } from './MessageItem.tsx' @@ -267,17 +268,21 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selec /** One command lifecycle row: keyed dispatch on the command name with the * generic card as the render-site fallback (zero registration required). A * run-less cross-window node has no name and always lands on the fallback. */ -const CommandRow = memo(function CommandRow({ renderSlot, node, t }: { +const CommandRow = memo(function CommandRow({ renderSlot, node, compaction, t }: { renderSlot: RenderToolRow node: CommandNode + compaction?: Extract t: ChatViewSlotProps['t'] }) { - const owner = useMemo(() => ({ node }), [node]) + const owner = useMemo(() => ({ node, ...compaction === undefined ? {} : { compaction } }), [compaction, node]) + const fallback = node.name === 'compact' || compaction !== undefined + ? + : return (
{renderSlot('conversation.chat.commandview', owner, { entryKey: node.name ?? '', - fallback: , + fallback, })}
) @@ -580,6 +585,16 @@ export function ChatView({ /> ) } + if (item.kind === 'command-compaction') { + return ( + + ) + } const node: ConversationNode = item.node if (node.kind === 'assistant') { const timing = actionSeqs.has(node.seq) ? turnTimings.get(node.turn) : undefined @@ -642,9 +657,17 @@ export function ChatView({
{renderItem(item)}
diff --git a/packages/client/ui-conversation/src/client/chat/CompactionCommandCard.tsx b/packages/client/ui-conversation/src/client/chat/CompactionCommandCard.tsx new file mode 100644 index 0000000000..d2401e49c6 --- /dev/null +++ b/packages/client/ui-conversation/src/client/chat/CompactionCommandCard.tsx @@ -0,0 +1,40 @@ +// CompactionCommandCard: the `/compact` command's running row and its +// successful checkpoint disclosure. Outcomes without a checkpoint keep the +// generic command card so no-history, cancellation, and failures retain their +// complete handler-authored text. + +import { IconApiOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ChatViewSlotProps, CommandRowOwnerProps } from '../contract/slots.ts' +import { CompactionItem } from './CompactionItem.tsx' +import { GenericCommandCard } from './GenericCommandCard.tsx' +import { ToolRow } from './ToolRow.tsx' + +interface CompactionCommandCardProps extends CommandRowOwnerProps { + t: ChatViewSlotProps['t'] +} + +/** Render one manual compaction lifecycle without duplicating its checkpoint marker. */ +export function CompactionCommandCard({ node, compaction, t }: CompactionCommandCardProps) { + if (compaction !== undefined) { + return ( + + ) + } + if (node.outcome !== null) return + return ( + } + title={node.name ?? 'compact'} + summary={t('message.compaction.running')} + body={null} + state="running" + /> + ) +} diff --git a/packages/client/ui-conversation/src/client/chat/CompactionItem.tsx b/packages/client/ui-conversation/src/client/chat/CompactionItem.tsx index 82922dd97e..7049688cc0 100644 --- a/packages/client/ui-conversation/src/client/chat/CompactionItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/CompactionItem.tsx @@ -18,6 +18,10 @@ import css from './MessageItem.module.css' interface CompactionItemProps { node: CompactionSummaryNode + /** Optional command title for a manual compaction folded into this marker. */ + title?: string + /** Command settlement text used only when the summary provenance page is absent. */ + fallbackSummary?: string | null /** The owning view's locale seat. */ t: ChatViewSlotProps['t'] } @@ -27,10 +31,22 @@ interface CompactionItemProps { * @param props - the marker node off the snapshot cache. * @returns the marker row, with the summary disclosure when one is available. */ -export const CompactionItem = memo(function CompactionItem({ node, t }: CompactionItemProps) { +export const CompactionItem = memo(function CompactionItem({ + node, + title, + fallbackSummary, + t, +}: CompactionItemProps) { const [expanded, setExpanded] = useState(false) const expandable = node.summary !== null const open = expandable && expanded + const summary = node.shadowedItemCount !== null && node.shadowedTokenCount !== null + ? t('message.compaction.completed', { + items: node.shadowedItemCount, + tokens: node.shadowedTokenCount, + }) + : fallbackSummary + ?? (expandable ? t('message.compaction.expand') : t('message.compaction.unavailable')) return (
{open && node.summary !== null &&
} diff --git a/packages/client/ui-conversation/src/client/chat/chat-flow.ts b/packages/client/ui-conversation/src/client/chat/chat-flow.ts index e3b8e5ba2c..22146ddb31 100644 --- a/packages/client/ui-conversation/src/client/chat/chat-flow.ts +++ b/packages/client/ui-conversation/src/client/chat/chat-flow.ts @@ -9,13 +9,48 @@ * flow share their gates. */ import type { - AssistantBlock, ConversationNode, ConversationSnapshot, ToolResultNode, + AssistantBlock, CommandNode, CompactionSummaryNode, ConversationNode, ConversationSnapshot, ToolResultNode, } from '@deepseek-ai/dsh-client-runtime/client' /** One renderable flow item; key is the React key and the parent's identity unit. */ export type ChatFlowItem = | { kind: 'node'; key: string; node: ConversationNode } | { kind: 'tool-group'; key: string; results: readonly ToolResultNode[] } + | { + kind: 'command-compaction' + key: string + command: CommandNode + compaction: CompactionSummaryNode + } + +/** Match explicit command outcome references to exactly one compaction checkpoint. */ +function commandCompactionPairs(nodes: readonly ConversationNode[]): { + readonly byCommandId: ReadonlyMap + readonly byCompactionSeq: ReadonlyMap +} { + const commandsBySource = new Map() + for (const node of nodes) { + if (node.kind !== 'command' || node.name !== 'compact' || node.outcome?.kind !== 'success') continue + const source = node.outcome.sourceEventSeq + if (source === undefined) continue + commandsBySource.set(source, commandsBySource.has(source) ? null : node) + } + const compactionsBySummary = new Map() + for (const node of nodes) { + if (node.kind !== 'compaction' || node.summaryEventSeq === null) continue + const summary = node.summaryEventSeq + compactionsBySummary.set(summary, compactionsBySummary.has(summary) ? null : node) + } + const byCommandId = new Map() + const byCompactionSeq = new Map() + for (const [source, command] of commandsBySource) { + const compaction = compactionsBySummary.get(source) + if (command === null || compaction === undefined || compaction === null) continue + byCommandId.set(command.commandId, compaction) + byCompactionSeq.set(compaction.seq, command) + } + return { byCommandId, byCompactionSeq } +} /** * True when the node has model-visible text content worth IconActions chrome. @@ -115,9 +150,29 @@ export function assistantBranchSeqs( */ export function deriveChatFlow(nodes: readonly ConversationNode[]): ChatFlowItem[] { const items: ChatFlowItem[] = [] + const pairs = commandCompactionPairs(nodes) let group: ToolResultNode[] | null = null for (const node of nodes) { if (rendersNothing(node)) continue + if (node.kind === 'command' && pairs.byCommandId.has(node.commandId)) { + group = null + continue + } + if (node.kind === 'compaction') { + group = null + const command = pairs.byCompactionSeq.get(node.seq) + if (command !== undefined) { + items.push({ + kind: 'command-compaction', + key: `c${command.commandId}`, + command, + compaction: node, + }) + } else { + items.push({ kind: 'node', key: `n${node.seq}`, node }) + } + continue + } if (node.kind === 'tool-result') { if (group === null) { group = [node] @@ -138,7 +193,13 @@ export function deriveChatFlow(nodes: readonly ConversationNode[]): ChatFlowItem } } else { group = null - items.push({ kind: 'node', key: `n${node.seq}`, node }) + items.push({ + kind: 'node', + key: node.kind === 'command' && node.name === 'compact' + ? `c${node.commandId}` + : `n${node.seq}`, + node, + }) } } return items diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 0284784e6e..be57f08523 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -3,7 +3,7 @@ import type { ReactNode, RefObject } from 'react' import type { InjectFace, MaybeSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook, } from '@deepseek-ai/dsh-client-ui-slots' -import type { CommandNode, ConversationNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' +import type { CommandNode, CompactionSummaryNode, ConversationNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' import type { ComposerBlock } from '../input/blocks.ts' import type { ComposerKeyboard, EditSelection, InputActions, InputNotice, InputState } from '../input/contract.ts' @@ -217,14 +217,16 @@ export type ToolRowProps = PropsRuntime<'conversation.chat.toolview'> /** * Owner share of the per-command row slot: the frozen {@link CommandNode} * slice off the snapshot (cache-stable reference — memo premise). The node - * carries the whole lifecycle (structured name/args, pairing id, - * outcome-or-executing), so a - * registrant needs no second data channel; domain state arrives through its - * own projection cell. + * carries the whole lifecycle (structured name/args, pairing id, and + * outcome-or-executing). A successful domain command may also carry the + * explicitly linked projection node needed to fold two log records into one + * presentation row. */ export interface CommandRowOwnerProps { /** Folded command lifecycle node (run + optional done). */ node: CommandNode + /** Explicitly linked compaction checkpoint for the settled `/compact` presentation. */ + compaction?: CompactionSummaryNode } /** Full props of a registered command-row component (same shape rule as {@link ToolRowProps}). */ diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index df107d2cd2..11e852a8b6 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -80,6 +80,8 @@ export const zh = { 'message.context.recall.truncated': '已截断', 'message.steering': '插话', 'message.compaction': '上下文已压缩', + 'message.compaction.running': '正在压缩…', + 'message.compaction.completed': '已压缩 {items} 条历史记录(约 {tokens} tokens)', 'message.compaction.expand': '点击查看压缩摘要', 'message.compaction.unavailable': '压缩摘要不可用', 'message.unknownSurface': '未知 surface 事件:{type}', @@ -220,6 +222,8 @@ export const en = { 'message.context.recall.truncated': 'truncated', 'message.steering': 'Interjection', 'message.compaction': 'Context compacted', + 'message.compaction.running': 'Compacting context…', + 'message.compaction.completed': 'Compacted {items} history items (~{tokens} tokens)', 'message.compaction.expand': 'View compaction summary', 'message.compaction.unavailable': 'Compaction summary unavailable', 'message.unknownSurface': 'Unknown surface event: {type}', diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index 3122b0fdc7..7d5ba6a528 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -691,11 +691,15 @@ describe('MessageItem arms', () => { , ) const row = view.getByRole('button', { name: /上下文已压缩/ }) expect(row.getAttribute('aria-expanded')).toBe('false') + expect(view.getByText('已压缩 16 条历史记录(约 11309 tokens)')).toBeTruthy() expect(view.queryByText(/保留的事实/)).toBeNull() fireEvent.click(row) expect(row.getAttribute('aria-expanded')).toBe('true') @@ -705,7 +709,10 @@ describe('MessageItem arms', () => { }) it('a marker whose provenance fell outside the window is not expandable', () => { - const view = render() + const view = render() const row = view.getByRole('button', { name: /上下文已压缩/ }) expect(row).toHaveProperty('disabled', true) expect(row.getAttribute('aria-expanded')).toBeNull() diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index b8cd94de52..a0213a2407 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Profiler } from 'react' import { act, cleanup, fireEvent, render, within } from '@testing-library/react' import type { - AssistantMessageNode, CommandNode, ConversationNode, ConversationSnapshot, + AssistantMessageNode, CommandNode, CompactionSummaryNode, ConversationNode, ConversationSnapshot, ModelRetryNode, RunningToolCall, SessionId, SessionListState, ToolResultNode, TurnErrorNode, UserMessageNode, WorkspaceListState, } from '@deepseek-ai/dsh-client-runtime/client' @@ -93,6 +93,19 @@ const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode const runningCall = (callId: string, name = 'bash'): RunningToolCall => ({ callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, time: 1_000, callView: null, }) +const command = (over: Partial = {}): CommandNode => ({ + kind: 'command', seq: 5, time: 5_000, commandId: 'cmd-1' as CommandNode['commandId'], + name: 'plan', args: '', outcome: { kind: 'success', text: '已进入 plan mode' }, + ...over, +}) +const compaction = (over: Partial = {}): CompactionSummaryNode => ({ + kind: 'compaction', seq: 8, time: 8_000, + summary: '## 压缩摘要\n\n保留的事实。', + summaryEventSeq: 7, + shadowedItemCount: 16, + shadowedTokenCount: 11_309, + ...over, +}) /** Empty sessions-list hook for the global standard-kit seat. */ function emptySessions() { @@ -212,6 +225,60 @@ describe('chat-flow derivation', () => { expect(updated[1]?.kind === 'node' && updated[1].node).toBe(second) }) + it('folds a successful /compact lifecycle into its explicitly linked checkpoint', () => { + const running = command({ + seq: 1, + commandId: 'cmd-compact' as CommandNode['commandId'], + name: 'compact', + outcome: null, + }) + expect(flowKeys(deriveChatFlow([user(0, 'before'), running]))).toBe('n0|ccmd-compact') + + const settled = { + ...running, + outcome: { kind: 'success' as const, text: 'Compacted 16 history items.', sourceEventSeq: 3 }, + } + const checkpoint = compaction({ seq: 4, summaryEventSeq: 3 }) + const items = deriveChatFlow([user(0, 'before'), settled, user(2, 'injected while compacting'), checkpoint]) + expect(flowKeys(items)).toBe('n0|n2|ccmd-compact') + expect(items.at(-1)).toEqual({ + kind: 'command-compaction', + key: 'ccmd-compact', + command: settled, + compaction: checkpoint, + }) + }) + + it('keeps automatic, unlinked, and ambiguously linked compactions as separate rows', () => { + const automatic = compaction({ seq: 2, summaryEventSeq: 1 }) + expect(flowKeys(deriveChatFlow([automatic]))).toBe('n2') + + const first = command({ + seq: 3, + commandId: 'cmd-a' as CommandNode['commandId'], + name: 'compact', + outcome: { kind: 'success', sourceEventSeq: 9 }, + }) + const second = command({ + seq: 4, + commandId: 'cmd-b' as CommandNode['commandId'], + name: 'compact', + outcome: { kind: 'success', sourceEventSeq: 9 }, + }) + const ambiguous = compaction({ seq: 10, summaryEventSeq: 9 }) + expect(flowKeys(deriveChatFlow([first, second, ambiguous]))).toBe('ccmd-a|ccmd-b|n10') + + const sole = command({ + seq: 11, + commandId: 'cmd-sole' as CommandNode['commandId'], + name: 'compact', + outcome: { kind: 'success', sourceEventSeq: 12 }, + }) + const duplicateA = compaction({ seq: 13, summaryEventSeq: 12 }) + const duplicateB = compaction({ seq: 14, summaryEventSeq: 12 }) + expect(flowKeys(deriveChatFlow([sole, duplicateA, duplicateB]))).toBe('ccmd-sole|n13|n14') + }) + it('skips render-nothing assistant nodes so tool runs stay one group', () => { // A tool-call-only step message (and blank text/reasoning) renders nothing: // it must not split the run into two groups with an empty line between. @@ -1172,11 +1239,6 @@ describe('ChatView', () => { }) it('renders command nodes as durable rows: settled text, error state, executing spinner, run-less soft-fall', () => { - const command = (over: Partial): CommandNode => ({ - kind: 'command', seq: 5, time: 5_000, commandId: 'cmd-1' as CommandNode['commandId'], - name: 'plan', args: '', outcome: { kind: 'success', text: '已进入 plan mode' }, - ...over, - }) // Settled success: the bare command name is the title, the outcome text // the summary — neither the dispatched `/` nor its arguments reach the row // (the settlement text already says what the command did). @@ -1211,4 +1273,62 @@ describe('ChatView', () => { expect(ov.getByText('命令')).toBeTruthy() expect(ov.getByText('已完成')).toBeTruthy() }) + + it('renders /compact as one stateful disclosure from running through completion', () => { + const running = command({ + commandId: 'cmd-compact' as CommandNode['commandId'], + name: 'compact', + outcome: null, + }) + const h = makeHarness({ nodes: [running] }) + const view = render() + expect(view.getByText('正在压缩…')).toBeTruthy() + expect(view.container.querySelector('[data-state="running"]')).not.toBeNull() + + act(() => { + h.set({ + nodes: [{ + ...running, + outcome: { + kind: 'success', + text: 'Compacted 16 history items (~11309 tokens).', + sourceEventSeq: 7, + }, + }, compaction()], + }) + }) + + expect(view.queryByText('正在压缩…')).toBeNull() + expect(view.queryByText('上下文已压缩')).toBeNull() + expect(view.getByText('已压缩 16 条历史记录(约 11309 tokens)')).toBeTruthy() + const row = view.getByRole('button', { name: /compact/ }) + expect(row.getAttribute('aria-expanded')).toBe('false') + expect(view.queryByText('保留的事实。')).toBeNull() + fireEvent.click(row) + expect(row.getAttribute('aria-expanded')).toBe('true') + expect(view.getByRole('heading', { name: '压缩摘要' })).toBeTruthy() + }) + + it('keeps /compact no-history and error settlements on the generic command row', () => { + const noHistory = makeHarness({ + nodes: [command({ + name: 'compact', + outcome: { kind: 'success', text: 'No compactable history yet.' }, + })], + }) + const noHistoryView = render() + expect(noHistoryView.getByText('No compactable history yet.')).toBeTruthy() + expect(noHistoryView.queryByRole('button')).toBeNull() + + const failed = makeHarness({ + nodes: [command({ + commandId: 'cmd-compact-failed' as CommandNode['commandId'], + name: 'compact', + outcome: { kind: 'error', text: 'Compaction cancelled.' }, + })], + }) + const failedView = render() + expect(failedView.getByText('Compaction cancelled.')).toBeTruthy() + expect(failedView.container.querySelector('[data-state="error"]')).not.toBeNull() + }) }) diff --git a/packages/client/ui-trajectory/tests/layout.spec.tsx b/packages/client/ui-trajectory/tests/layout.spec.tsx index bd25cc4d51..544199e345 100644 --- a/packages/client/ui-trajectory/tests/layout.spec.tsx +++ b/packages/client/ui-trajectory/tests/layout.spec.tsx @@ -324,7 +324,10 @@ describe('deriveTrajectoryLayout', () => { }, // A landed compaction renders no cell, but is still a real log position, // so it moves the cursor after the visible context row. - { kind: 'compaction', seq: 5, time: 9_500, summary: 'checkpoint facts' }, + { + kind: 'compaction', seq: 5, time: 9_500, summary: 'checkpoint facts', + summaryEventSeq: 4, shadowedItemCount: 2, shadowedTokenCount: 100, + }, { kind: 'assistant', seq: 6, time: 10_000, turn: 1, step: 0, blocks: [{ kind: 'text', text: 'done' }], diff --git a/packages/compact/command-compact/README.i18n.yaml b/packages/compact/command-compact/README.i18n.yaml index c39570db18..35ebe66e2b 100644 --- a/packages/compact/command-compact/README.i18n.yaml +++ b/packages/compact/command-compact/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/compact/command-compact/README.md -README.md: a32a6aeb9957f0fd5f8cff58b1edbb9bc29a4e3d -README.zh.md: c678f522115d9b0fd414b2f290b3cb54ce690722 +README.md: 54f341e39447a423964b7d7435cfb638857eda6e +README.zh.md: d4a122b8a19cdf907212ad019b2528ae52d03886 diff --git a/packages/compact/command-compact/README.md b/packages/compact/command-compact/README.md index a32a6aeb99..54f341e394 100644 --- a/packages/compact/command-compact/README.md +++ b/packages/compact/command-compact/README.md @@ -12,7 +12,7 @@ Human-facing `/compact` control over [`ctx.compact`](../compact/README.md). The | `/compact` with no compactable history | `No compactable history yet.` — no marker or surface mutation is written. | | `/compact ` | `Usage: /compact (no arguments)` — the command takes no arguments and calls no compaction backend. | -The command is backend-independent: it depends only on `compactNow(agent, signal)`. The invoking agent is the exact target, and the dispatching UI's cancellation signal is forwarded through the seam. Every resolved invocation records the executor-owned log-only pair `command/run` / `command/done`; neither event joins model history. +The command is backend-independent: it depends only on `compactNow(agent, signal)`. The invoking agent is the exact target, and the dispatching UI's cancellation signal is forwarded through the seam. Every resolved invocation records the executor-owned log-only pair `command/run` / `command/done`; neither event joins model history. On success, `command/done.sourceEventSeq` names the transaction's `compact/summary` event so a presentation can fold the command lifecycle into its checkpoint without parsing result text or assuming adjacent rows. Expected `ManualCompactionError` codes become stable direct errors: diff --git a/packages/compact/command-compact/README.zh.md b/packages/compact/command-compact/README.zh.md index c678f52211..d4a122b8a1 100644 --- a/packages/compact/command-compact/README.zh.md +++ b/packages/compact/command-compact/README.zh.md @@ -12,7 +12,7 @@ | `/compact`,但没有可压缩历史 | `No compactable history yet.`:不会写入标记,也不会变更 surface。 | | `/compact ` | `Usage: /compact (no arguments)`:该命令不接受参数,也不会调用压缩后端。 | -该命令与后端无关,只依赖 `compactNow(agent, signal)`。调用该命令的 agent(智能体)就是操作的确切目标,发起分发的 UI 会通过 seam 转发取消信号。每次完成的调用都会记录执行器所属的纯日志事件对 `command/run` / `command/done`;两者都不进入模型历史。 +该命令与后端无关,只依赖 `compactNow(agent, signal)`。调用该命令的 agent(智能体)就是操作的确切目标,发起分发的 UI 会通过 seam 转发取消信号。每次完成的调用都会记录执行器所属的纯日志事件对 `command/run` / `command/done`;两者都不进入模型历史。成功时,`command/done.sourceEventSeq` 会指明该事务的 `compact/summary` 事件,让呈现层无须解析结果文本或假定两行相邻,即可将命令生命周期归并到对应检查点中。 预期的 `ManualCompactionError` 代码会成为稳定的直接错误: diff --git a/packages/compact/command-compact/src/index.ts b/packages/compact/command-compact/src/index.ts index 2390833bff..4ac171a689 100644 --- a/packages/compact/command-compact/src/index.ts +++ b/packages/compact/command-compact/src/index.ts @@ -68,6 +68,7 @@ async function executeCompact( return { kind: 'success', text: `Compacted ${result.shadowedSeqs.length} history items (~${result.shadowedTokenCount} tokens).`, + sourceEventSeq: result.summarySeq, } } catch (error: unknown) { if (invocation.signal.aborted) return { kind: 'error', text: 'Compaction cancelled.' } diff --git a/packages/compact/command-compact/tests/command-compact.spec.ts b/packages/compact/command-compact/tests/command-compact.spec.ts index 71af9534e4..6922778a26 100644 --- a/packages/compact/command-compact/tests/command-compact.spec.ts +++ b/packages/compact/command-compact/tests/command-compact.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import type { Agent } from '@deepseek-ai/dsh-agent' -import CommandService from '@deepseek-ai/dsh-commands' +import CommandService, { type CommandResult } from '@deepseek-ai/dsh-commands' import { CompactService, ManualCompactionError, @@ -15,9 +15,9 @@ import { Session, SessionId } from '@deepseek-ai/dsh-session' import * as commandCompact from '@deepseek-ai/dsh-command-compact' const RESULT: CompactionResult = { - startSeq: 10, - summarySeq: 11, - endSeq: 13, + startSeq: 1, + summarySeq: 2, + endSeq: 3, summary: [{ type: 'text', text: 'summary' }], shadowedRange: { start: 1, end: 7 }, shadowedSeqs: [1, 3, 7], @@ -49,10 +49,24 @@ class StubCompactService extends CompactService { this.calls.push({ agent, signal }) if (this.operation !== undefined) return this.operation() return this.failure === undefined - ? Promise.resolve(this.result) + ? Promise.resolve(this.result === null ? null : this.appendResult(agent, this.result)) // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- exercise arbitrary backend rejection values. : Promise.reject(this.failure) } + + private appendResult(agent: ManualCompactAgentContext, result: CompactionResult): CompactionResult { + agent.session.append('compact/start', { turn: null }) + agent.session.append('compact/summary', { + summary: result.summary, + shadowedRange: result.shadowedRange, + shadowedSeqs: result.shadowedSeqs, + shadowedTokenCount: result.shadowedTokenCount, + provider: 'command-test', + model: 'command-test', + }) + agent.session.append('compact/end', { turn: null }) + return result + } } interface Harness { @@ -91,9 +105,11 @@ async function run( function expectLastLifecycle( test: Harness, args: string, - outcome: { readonly kind: 'success' | 'error'; readonly text?: string }, + outcome: CommandResult, ): string { - const lifecycle = test.agent.session.events.slice(-2) + const lifecycle = test.agent.session.events + .filter(event => event.type === 'command/run' || event.type === 'command/done') + .slice(-2) const runEvent = lifecycle[0] const doneEvent = lifecycle[1] if (runEvent?.type !== 'command/run' || doneEvent?.type !== 'command/done') { @@ -149,6 +165,7 @@ describe('/compact human command', () => { expect(execution.result).toEqual({ kind: 'success', text: 'Compacted 3 history items (~42 tokens).', + sourceEventSeq: RESULT.summarySeq, }) expect(execution.commandId).toBe(expectLastLifecycle(test, '', execution.result)) expect(test.compact.calls).toEqual([{ agent: test.agent, signal: controller.signal }]) diff --git a/packages/compact/command-compact/tests/loader-composition.spec.ts b/packages/compact/command-compact/tests/loader-composition.spec.ts index bbd9bcfcb1..5a5d37d8b1 100644 --- a/packages/compact/command-compact/tests/loader-composition.spec.ts +++ b/packages/compact/command-compact/tests/loader-composition.spec.ts @@ -21,7 +21,7 @@ import { Session, SessionId } from '@deepseek-ai/dsh-session' const RESULT: CompactionResult = { startSeq: 1, summarySeq: 2, - endSeq: 4, + endSeq: 3, summary: [{ type: 'text', text: 'loader summary' }], shadowedRange: { start: 3, end: 8 }, shadowedSeqs: [3, 5, 8], @@ -42,9 +42,19 @@ class LoaderCompactService extends CompactService { } override compactNow( - _agent: ManualCompactAgentContext, + agent: ManualCompactAgentContext, _signal: AbortSignal, ): Promise { + agent.session.append('compact/start', { turn: null }) + agent.session.append('compact/summary', { + summary: RESULT.summary, + shadowedRange: RESULT.shadowedRange, + shadowedSeqs: RESULT.shadowedSeqs, + shadowedTokenCount: RESULT.shadowedTokenCount, + provider: 'loader-test', + model: 'loader-test', + }) + agent.session.append('compact/end', { turn: null }) return Promise.resolve(RESULT) } } @@ -108,6 +118,7 @@ describe('command-compact real Loader composition', () => { expect(execution.result).toEqual({ kind: 'success', text: 'Compacted 3 history items (~99 tokens).', + sourceEventSeq: RESULT.summarySeq, }) expect(session.events.map(event => ({ type: event.type, data: event.data }))).toEqual([ { @@ -119,12 +130,32 @@ describe('command-compact real Loader composition', () => { source: { kind: 'user' }, }, }, + { + type: 'compact/start', + data: { turn: null }, + }, + { + type: 'compact/summary', + data: { + summary: RESULT.summary, + shadowedRange: RESULT.shadowedRange, + shadowedSeqs: RESULT.shadowedSeqs, + shadowedTokenCount: RESULT.shadowedTokenCount, + provider: 'loader-test', + model: 'loader-test', + }, + }, + { + type: 'compact/end', + data: { turn: null }, + }, { type: 'command/done', data: { commandId: execution.commandId, kind: 'success', text: 'Compacted 3 history items (~99 tokens).', + sourceEventSeq: RESULT.summarySeq, }, }, ]) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index b7fd6d3c5a..01ccdc5bf6 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1793,7 +1793,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'CommandResult', - declaration: 'export type CommandResult = {\n readonly kind: \'success\';\n readonly text?: string;\n} | {\n readonly kind: \'error\';\n readonly text: string;\n};', + declaration: 'export type CommandResult = {\n readonly kind: \'success\';\n readonly text?: string;\n readonly sourceEventSeq?: number;\n} | {\n readonly kind: \'error\';\n readonly text: string;\n};', }, { name: 'CompactAgentContext', diff --git a/packages/ui/commands/README.i18n.yaml b/packages/ui/commands/README.i18n.yaml index 751344084c..be55a19ca3 100644 --- a/packages/ui/commands/README.i18n.yaml +++ b/packages/ui/commands/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/commands/README.md -README.md: 3105ae1a866e03f3c8f621bfe588df15ee38957e -README.zh.md: 704a2daefb65fde12ca85d1c9051ad762c5ccc70 +README.md: 1709bdcdce4e43d98cfea5ff3972ab95bfd3c33b +README.zh.md: 569f2aa8293793b26d63ee16e3ea7600e04a8397 diff --git a/packages/ui/commands/README.md b/packages/ui/commands/README.md index 3105ae1a86..1709bdcdce 100644 --- a/packages/ui/commands/README.md +++ b/packages/ui/commands/README.md @@ -8,11 +8,11 @@ Plugin-owned human-command registry consumed by interactive UI adapters. The [pl `ctx.commands.register(definition)` registers one lowercase command name, description, optional unstructured-input hint, optional `recordInput` policy, and abortable handler. `recordInput` defaults to true; a command whose authoritative domain event owns the payload sets it to false so `command/run` omits `args` instead of duplicating the input. A registered command is available to every composed command adapter; a plugin that is incompatible with a deployment does not register there. A plain-context registration is global. A command-producing plugin mounted beneath `agent.ctx` declares its own `commands` injection and creates an exact agent-scoped definition; it shadows a global definition with the same name. This child-injection shape preserves the agent scope without making the core agent loop depend on a UI service. Duplicate names within one layer fail during registration. Every disposer is the exact Cordis effect disposer, and registration or removal notifies every `commands/change` observer so live adapters can refresh discovery; observer failures are logged and cannot veto the registry mutation or starve later observers. -`list(agent)` returns immutable, name-sorted descriptors after scoped shadowing. `find(agent, name)` returns the corresponding definition. `execute(agent, line, signal)` uses `parseCommand()` and runs only a known command, returning the settled `CommandExecution` (the normalized result plus the lifecycle pairing `commandId`) or `undefined` for invalid syntax or unknown names. A resolved command's lifecycle is logged on the receiving agent's session as the log-only pair `command/run` (before the handler, with a minted `commandId`, the parser's structured name, the issuing `CommandSource`, and `args` unless `recordInput` is false) and `command/done` (at settlement, with the outcome kind and verbatim text; a thrown or aborted handler settles as `kind: 'error'`). Admission misses log nothing. Both are direct standalone appends on the receiving agent's session: no turn wraps them, and persistence drains them through ordinary checkpoints and teardown. +`list(agent)` returns immutable, name-sorted descriptors after scoped shadowing. `find(agent, name)` returns the corresponding definition. `execute(agent, line, signal)` uses `parseCommand()` and runs only a known command, returning the settled `CommandExecution` (the normalized result plus the lifecycle pairing `commandId`) or `undefined` for invalid syntax or unknown names. A resolved command's lifecycle is logged on the receiving agent's session as the log-only pair `command/run` (before the handler, with a minted `commandId`, the parser's structured name, the issuing `CommandSource`, and `args` unless `recordInput` is false) and `command/done` (at settlement, with the outcome kind and verbatim text; a successful result may also name an earlier non-command authoritative domain event through `sourceEventSeq`; a thrown or aborted handler settles as `kind: 'error'`). Admission misses log nothing. Both are direct standalone appends on the receiving agent's session: no turn wraps them, and persistence drains them through ordinary checkpoints and teardown. `parseCommand()` recognizes a slash at byte zero, a lowercase name containing letters, digits, `_`, or `-`, and either end-of-input or whitespace. It returns every byte after the name as `rawInput`, including separator whitespace; consumers own their command-specific grammar and may normalize only what that grammar permits. -Handlers return `success` or `error` plus optional UI text. Results are rendered directly by the adapter and never enter model history. The registry never submits `rawInput` to the agent implicitly; a command producer may explicitly schedule model-visible work through the receiving `Agent`, in which case that producer owns the resulting message contract. The registry races handler completion against the supplied abort signal, but an uncooperative handler may continue its own external side effects after the caller stops awaiting it. +Handlers return `success` or `error` plus optional UI text. A successful handler may also return `sourceEventSeq` when an earlier domain event owns a richer presentation; the lifecycle invariant requires that reference to be a prior non-command event in the same session. Results are rendered directly by the adapter and never enter model history. The registry never submits `rawInput` to the agent implicitly; a command producer may explicitly schedule model-visible work through the receiving `Agent`, in which case that producer owns the resulting message contract. The registry races handler completion against the supplied abort signal, but an uncooperative handler may continue its own external side effects after the caller stops awaiting it. ## Composition diff --git a/packages/ui/commands/README.zh.md b/packages/ui/commands/README.zh.md index 704a2daefb..569f2aa829 100644 --- a/packages/ui/commands/README.zh.md +++ b/packages/ui/commands/README.zh.md @@ -8,11 +8,11 @@ `ctx.commands.register(definition)` 注册一个小写命令名称、描述、可选的非结构化输入提示、可选的 `recordInput` 策略,以及可中止的处理器。`recordInput` 默认为 true;若载荷由命令的权威领域事件持有,该命令会将 `recordInput` 设为 false,让 `command/run` 省略 `args`,避免重复记录输入。每个已注册命令都可供所有已组合的命令适配器使用;与某项部署不兼容的插件不会在此注册。普通上下文中的注册全局生效。在 `agent.ctx` 下挂载的命令生产插件会声明自身的 `commands` 注入,并创建精确限定到该 agent(智能体)的定义;该定义会遮蔽同名的全局定义。这种子级注入形态保留了 agent 作用域,同时不会让核心 agent loop(智能体循环)依赖 UI 服务。同一层中的名称重复会在注册时失败。每个 disposer 都是 Cordis effect 返回的确切 disposer;注册或移除命令时,系统会通知每个 `commands/change` 观察者,使运行中的适配器能够刷新发现结果。观察者失败会写入日志,既不能否决注册表变更,也不能阻止后续观察者运行。 -`list(agent)` 在应用作用域遮蔽后,返回按名称排序的不可变描述符。`find(agent, name)` 返回相应定义。`execute(agent, line, signal)` 使用 `parseCommand()`,且只运行已知命令,返回已结算的 `CommandExecution`(规范化结果加生命周期配对 `commandId`);语法无效或名称未知时返回 `undefined`。已解析命令的生命周期会以 log-only 事件对的形式记录在接收 agent 的会话日志中:`command/run`(进入处理器前记录,携带新生成的 `commandId`、解析器的结构化名称、发起方 `CommandSource`,以及 `args`(`recordInput` 为 false 时省略))与 `command/done`(结算时记录,携带结果类型与原样文本;处理器抛出或被中止时以 `kind: 'error'` 结算)。未通过准入的输入不记录任何事件。两者都直接独立追加到接收 agent 的会话中:没有轮次包裹它们,持久化机制会在常规检查点和销毁期间排空这些事件。 +`list(agent)` 在应用作用域遮蔽后,返回按名称排序的不可变描述符。`find(agent, name)` 返回相应定义。`execute(agent, line, signal)` 使用 `parseCommand()`,且只运行已知命令,返回已结算的 `CommandExecution`(规范化结果加生命周期配对 `commandId`);语法无效或名称未知时返回 `undefined`。已解析命令的生命周期会以 log-only 事件对的形式记录在接收 agent 的会话日志中:`command/run`(进入处理器前记录,携带新生成的 `commandId`、解析器的结构化名称、发起方 `CommandSource`,以及 `args`(`recordInput` 为 false 时省略))与 `command/done`(结算时记录,携带结果类型与原样文本;成功结果还可通过 `sourceEventSeq` 指向更早的一条非命令权威领域事件;处理器抛出或被中止时以 `kind: 'error'` 结算)。未通过准入的输入不记录任何事件。两者都直接独立追加到接收 agent 的会话中:没有轮次包裹它们,持久化机制会在常规检查点和销毁期间排空这些事件。 `parseCommand()` 识别位于第 0 字节的斜杠、由小写字母、数字、`_` 或 `-` 构成的名称,以及名称后紧接输入末尾或空白的形式。它将名称后的每个字节作为 `rawInput` 返回,其中包括分隔空白;消费方负责各命令专用的语法,只能执行该语法允许的规范化。 -处理器返回 `success` 或 `error`,并可附带 UI 文本。适配器直接渲染结果,结果绝不进入模型历史。注册表绝不会隐式地把 `rawInput` 提交给 agent;命令生产方可以通过接收命令的 `Agent` 显式安排模型可见工作,此时该生产方负责由此产生的消息契约。注册表会同时等待处理器完成和所提供的中止信号,以先发生者为准,但不响应中止的处理器可能在调用方停止等待后继续产生自身的外部副作用。 +处理器返回 `success` 或 `error`,并可附带 UI 文本。若更丰富的呈现由一条更早的领域事件持有,成功的处理器还可返回 `sourceEventSeq`;生命周期不变量要求该引用指向同一会话中更早的一条非命令事件。适配器直接渲染结果,结果绝不进入模型历史。注册表绝不会隐式地把 `rawInput` 提交给 agent;命令生产方可以通过接收命令的 `Agent` 显式安排模型可见工作,此时该生产方负责由此产生的消息契约。注册表会同时等待处理器完成和所提供的中止信号,以先发生者为准,但不响应中止的处理器可能在调用方停止等待后继续产生自身的外部副作用。 ## 组合 diff --git a/packages/ui/commands/src/index.ts b/packages/ui/commands/src/index.ts index b6dea581eb..64a9f8e8c8 100644 --- a/packages/ui/commands/src/index.ts +++ b/packages/ui/commands/src/index.ts @@ -47,7 +47,12 @@ export interface CommandInvocation { /** Expected command outcome rendered directly by the dispatching UI. */ export type CommandResult = - | { readonly kind: 'success'; readonly text?: string } + | { + readonly kind: 'success' + readonly text?: string + /** Earlier authoritative domain event that owns a richer presentation. */ + readonly sourceEventSeq?: number + } | { readonly kind: 'error'; readonly text: string } /** @@ -140,9 +145,15 @@ declare module '@deepseek-ai/dsh-session' { /** * The paired command settled. `kind`/`text` carry the handler's verbatim * outcome (a thrown/aborted handler settles as `kind: 'error'` with the - * rendered failure); presentation stays client-computed at render time. + * rendered failure). A successful command may identify the earlier + * authoritative domain event for a richer client-computed presentation. */ - 'command/done': { commandId: CommandId; kind: 'success' | 'error'; text?: string } + 'command/done': { + commandId: CommandId + kind: 'success' | 'error' + text?: string + sourceEventSeq?: number + } } } @@ -262,12 +273,20 @@ function normalizeResult(command: string, value: unknown): CommandResult { if (typeof value !== 'object' || value === null || !('kind' in value)) { throw new TypeError(`command "${command}" handler must return a CommandResult`) } - const result = value as { kind?: unknown; text?: unknown } + const result = value as { kind?: unknown; text?: unknown; sourceEventSeq?: unknown } if (result.kind === 'success') { if (result.text !== undefined && typeof result.text !== 'string') { throw new TypeError(`command "${command}" success text must be a string when supplied`) } - return Object.freeze(result.text === undefined ? { kind: 'success' } : { kind: 'success', text: result.text }) + if (result.sourceEventSeq !== undefined + && (!Number.isSafeInteger(result.sourceEventSeq) || (result.sourceEventSeq as number) < 0)) { + throw new TypeError(`command "${command}" success sourceEventSeq must be a non-negative safe integer when supplied`) + } + return Object.freeze({ + kind: 'success', + ...result.text === undefined ? {} : { text: result.text }, + ...result.sourceEventSeq === undefined ? {} : { sourceEventSeq: result.sourceEventSeq as number }, + }) } if (result.kind === 'error') { if (typeof result.text !== 'string' || result.text.trim().length === 0) { @@ -389,6 +408,9 @@ export class CommandService extends Service { this.appendLifecycle(agent.session, 'command/done', { commandId, kind: result.kind, ...result.text === undefined ? {} : { text: result.text }, + ...result.kind === 'success' && result.sourceEventSeq !== undefined + ? { sourceEventSeq: result.sourceEventSeq } + : {}, }) return Object.freeze({ commandId, result }) } diff --git a/packages/ui/commands/src/invariant.ts b/packages/ui/commands/src/invariant.ts index 858c31591c..792733c199 100644 --- a/packages/ui/commands/src/invariant.ts +++ b/packages/ui/commands/src/invariant.ts @@ -34,6 +34,16 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant if (runIds.get(session)?.has(event.data.commandId) !== true) { fail(`command/done ${JSON.stringify(event.data.commandId)} pairs no prior command/run in this log`) } + const source = event.data.sourceEventSeq + const sourceEvent = source === undefined ? undefined : session.events[source] + if (source !== undefined + && (event.data.kind !== 'success' + || !Number.isSafeInteger(source) || source < 0 || source >= event.seq + || sourceEvent?.seq !== source + || sourceEvent.type === 'command/run' + || sourceEvent.type === 'command/done')) { + fail(`command/done ${JSON.stringify(event.data.commandId)} has invalid sourceEventSeq ${String(source)}`) + } } for (const session of ctx.sessions.list()) { for (const event of session.events) validateEvent(session, event) diff --git a/packages/ui/commands/tests/commands.spec.ts b/packages/ui/commands/tests/commands.spec.ts index 7412325ab6..54b4227d19 100644 --- a/packages/ui/commands/tests/commands.spec.ts +++ b/packages/ui/commands/tests/commands.spec.ts @@ -320,6 +320,25 @@ describe('CommandService', () => { ]) }) + it('preserves an earlier authoritative domain-event reference on successful settlement', async () => { + const ctx = await mount() + const { agent } = await mintAgentScope(ctx, 'a') + const source = agent.session.append('turn/start', { turn: 1 }) + ctx.commands.register({ + name: 'linked', + description: 'Link outcome', + handler: () => ({ kind: 'success', text: 'linked', sourceEventSeq: source.seq }), + }) + + const execution = await ctx.commands.execute(agent, '/linked', new AbortController().signal) + + expect(execution?.result).toEqual({ kind: 'success', text: 'linked', sourceEventSeq: source.seq }) + expect(lifecycleOf(agent)).toMatchObject([ + { type: 'command/run', data: { name: 'linked' } }, + { type: 'command/done', data: { kind: 'success', text: 'linked', sourceEventSeq: source.seq } }, + ]) + }) + it('omits raw input from command/run when an authoritative domain event owns it', async () => { const ctx = await mount() const { agent } = await mintAgentScope(ctx, 'a') @@ -427,6 +446,9 @@ describe('CommandService', () => { [null, /CommandResult/], [{}, /CommandResult/], [{ kind: 'success', text: 1 }, /success text/], + [{ kind: 'success', sourceEventSeq: -1 }, /sourceEventSeq/], + [{ kind: 'success', sourceEventSeq: 1.5 }, /sourceEventSeq/], + [{ kind: 'success', sourceEventSeq: '1' }, /sourceEventSeq/], [{ kind: 'error', text: '' }, /error text/], [{ kind: 'error', text: 1 }, /error text/], [{ kind: 'future', text: 'x' }, /unknown result kind/], diff --git a/packages/ui/commands/tests/invariant.spec.ts b/packages/ui/commands/tests/invariant.spec.ts new file mode 100644 index 0000000000..8772a3b71e --- /dev/null +++ b/packages/ui/commands/tests/invariant.spec.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import * as CommandInvariant from '@deepseek-ai/dsh-commands/invariant' +import InvariantService, { InvariantError } from '@deepseek-ai/dsh-invariants' +import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session' +import { CommandId } from '@deepseek-ai/dsh-commands' + +async function mount(installCompanion = true): Promise<{ ctx: Context; session: Session }> { + const ctx = new Context() + await ctx.plugin(SessionStore) + const session = ctx.sessions.create(SessionId('commands-invariant')) + await ctx.plugin(InvariantService, { enabled: true }) + if (installCompanion) await ctx.plugin(CommandInvariant) + return { ctx, session } +} + +function appendRun(session: Session, id: string): void { + session.append('command/run', { + commandId: CommandId(id), + name: 'linked', + args: '', + source: { kind: 'user' }, + }) +} + +describe('command lifecycle invariants', () => { + it('accepts a success outcome linked to an earlier non-command domain event', async () => { + const { session } = await mount() + const source = session.append('turn/start', { turn: 1 }) + appendRun(session, 'cmd-valid') + + expect(() => { + session.append('command/done', { + commandId: CommandId('cmd-valid'), + kind: 'success', + sourceEventSeq: source.seq, + }) + }).not.toThrow() + }) + + it.each([-1, 1.5, 1])('rejects invalid or command-owned sourceEventSeq %s', async (sourceEventSeq) => { + const { session } = await mount() + appendRun(session, 'cmd-invalid') + + expect(() => { + session.append('command/done', { + commandId: CommandId('cmd-invalid'), + kind: 'success', + sourceEventSeq, + }) + }).toThrow(expect.objectContaining>({ + code: 'INVARIANT', + packageName: '@deepseek-ai/dsh-commands', + })) + }) + + it('rejects an error settlement carrying a success-only source reference', async () => { + const { session } = await mount() + const source = session.append('turn/start', { turn: 1 }) + appendRun(session, 'cmd-error-source') + + expect(() => { + session.append('command/done', { + commandId: CommandId('cmd-error-source'), + kind: 'error', + text: 'failed', + sourceEventSeq: source.seq, + }) + }).toThrow(expect.objectContaining>({ + code: 'INVARIANT', + packageName: '@deepseek-ai/dsh-commands', + })) + }) + + it('attributes an invalid durable prefix during late companion loading', async () => { + const { ctx, session } = await mount(false) + appendRun(session, 'cmd-late') + session.append('command/done', { + commandId: CommandId('cmd-late'), + kind: 'success', + sourceEventSeq: 0, + }) + + await expect(ctx.plugin(CommandInvariant)).rejects.toMatchObject({ + code: 'INVARIANT', + packageName: '@deepseek-ai/dsh-commands', + }) + }) +}) From f32aa54aeb0b526c6c04dd1212cce33e8751afa5 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 02:07:11 +0800 Subject: [PATCH 076/109] feat(cli)!: make dsh run the headless entrypoint --- ...19-gui-layering-and-rpc-protocol.i18n.yaml | 4 +- ...026-07-19-gui-layering-and-rpc-protocol.md | 6 +- ...-07-19-gui-layering-and-rpc-protocol.zh.md | 6 +- ...026-08-05-profile-plugin-bundles.i18n.yaml | 4 +- .../2026-08-05-profile-plugin-bundles.md | 2 +- .../2026-08-05-profile-plugin-bundles.zh.md | 2 +- ...3-cli-signal-shutdown-escalation.i18n.yaml | 4 +- ...26-08-03-cli-signal-shutdown-escalation.md | 4 +- ...08-03-cli-signal-shutdown-escalation.zh.md | 4 +- ...6-08-08-dsh-run-headless-command.i18n.yaml | 6 ++ .../2026-08-08-dsh-run-headless-command.md | 39 ++++++++++ .../2026-08-08-dsh-run-headless-command.zh.md | 39 ++++++++++ ...3-explicit-config-dsh-entrypoint.i18n.yaml | 2 +- ...08-03-explicit-config-dsh-entrypoint.zh.md | 2 +- README.i18n.yaml | 4 +- README.md | 2 +- README.zh.md | 2 +- apps/cli/README.i18n.yaml | 4 +- apps/cli/README.md | 4 +- apps/cli/README.zh.md | 4 +- apps/cli/reference/README.i18n.yaml | 4 +- apps/cli/reference/README.md | 10 ++- apps/cli/reference/README.zh.md | 10 ++- apps/cli/src/args.ts | 58 ++++++++++----- apps/cli/src/bin.ts | 11 ++- apps/cli/src/profile-boot.ts | 6 +- apps/cli/tests/args.spec.ts | 18 ++++- apps/cli/tests/built-bin.e2e.ts | 44 +++++++++++- apps/cli/tests/headless-shutdown.e2e.ts | 4 +- docs/config-catalog.md | 2 +- .../tests/fixtures/dsh-run.cordis.yml | 8 +++ .../headless-agent/tests/headless.snapshot.ts | 65 +++++++++++++++-- .../snapshots/dsh-run/session.expected.jsonl | 33 +++++++++ packages/bundle/headless/README.i18n.yaml | 4 +- packages/bundle/headless/README.md | 2 +- packages/bundle/headless/README.zh.md | 2 +- packages/bundle/headless/src/index.ts | 25 ++++--- .../bundle/headless/tests/headless.spec.ts | 72 ++++++++++++++----- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- 41 files changed, 429 insertions(+), 101 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.md create mode 100644 .agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.zh.md create mode 100644 examples/headless-agent/tests/fixtures/dsh-run.cordis.yml create mode 100644 examples/headless-agent/tests/snapshots/dsh-run/session.expected.jsonl diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml index bdb07d5f8a..65cbe478ae 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.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-19-gui-layering-and-rpc-protocol.md -2026-07-19-gui-layering-and-rpc-protocol.md: 34077302c53081f6ee9171d64dce9af342710d71 -2026-07-19-gui-layering-and-rpc-protocol.zh.md: bc51542ac8159ee7cba234b4ee8b4db47a7f9b58 +2026-07-19-gui-layering-and-rpc-protocol.md: 8e020e4fe9b60100671c0cf0e98e28532d850f94 +2026-07-19-gui-layering-and-rpc-protocol.zh.md: 55fa8084083aa83fbb4a38f8e41d2e5624b6e58e diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md index 34077302c5..8e020e4fe9 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md @@ -10,7 +10,7 @@ English | [中文](2026-07-19-gui-layering-and-rpc-protocol.zh.md) We need a UI integration layer. Beyond the existing ACP/stdio baseline, more product UI shapes are coming — Web (server), Electron, and others. We call these shapes Clients, uniformly, and want the following capabilities: -- One `dsh` process supporting both `dsh web` (serve) and `dsh -p` (headless) — one process, two modes (a design reservation) +- One `dsh` process supporting both `dsh web` (serve) and `dsh run` (headless) — one process, two modes (a design reservation) - Launching inside Electron with the same Web technology shape as `dsh web` That demands a stable layered responsibility model in the engineering codebase, so future client shapes plug in cleanly. @@ -31,7 +31,7 @@ Directories layer as follows: - **Fetch-arrival plugin packages** (`ui-layout`, `ui-sidebar`, `ui-conversation`, `ui-trajectory`): dual-entry — the root index is the node half (an empty `apply`, existing so the host Loader governs lifecycle and the web plugin registry discovers the package.json `dshClient` declaration); the implementation lives under `src/client/`, shipped as the `./client` subpath (a tsdown closure-factory bundle). Cross-plugin consumption of `/client` is type-only; value cooperation goes through cordis services. - `apps/` holds the externally exported application shapes, assembled from Client / Host mixtures. - `apps/web` (`dsh-frontend`) is the vite application: a thin `main.ts` over the shell surface exported by `dsh-client-web`. - - `apps/cli` (`@deepseek-ai/dsh`) dispatches shapes: `dsh web` = startHost + webserver + the built `dsh-frontend` dist; `dsh -p` = headless in-process calls, zero HTTP. + - `apps/cli` (`@deepseek-ai/dsh`) dispatches shapes: `dsh web` = startHost + webserver + the built `dsh-frontend` dist; `dsh run` = headless in-process calls, zero HTTP. - A future Electron shape reuses the same web client packages over an IPC fetch carrier. ``` @@ -215,7 +215,7 @@ All four quadrant full forms pass through `onEnvelope`; the base implementation | Subclass | Package | doFetch | Purpose | |---|---|---|---| -| `InProcessApiClient` | apiproxy itself | the injected `{ fetch }` handler | **The isomorphic point**: `new InProcessApiClient(toFetchHandler(api))` never touches the network yet runs the real wire serialization/zod/SSE framing — `dsh -p` headless is the protocol's second real consumer | +| `InProcessApiClient` | apiproxy itself | the injected `{ fetch }` handler | **The isomorphic point**: `new InProcessApiClient(toFetchHandler(api))` never touches the network yet runs the real wire serialization/zod/SSE framing — `dsh run` headless is the protocol's second real consumer | | `WebApiClient` | dsh-client-connection | `globalThis.fetch` uplink + one same-origin WebSocket downlink per logical stream | the browser shape; physical boundary in the [WebSocket downlink carrier](2026-08-04-websocket-downlink-carrier.md) | | `FixtureApiClient` | dsh-client-connection | unused (protocol-layer override) | serverless UI development (`?fixture`): overrides the `callUnary`/`openMux`/`openHost`/`respond` virtuals and is itself the fake server (frame rpcIds minted by it, semantics self-consistent) | | (future) IPC bridge subclass | apps/electron | IPC serialization round trip | swaps only doFetch; contract and base class unchanged | diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md index bc51542ac8..55fa808408 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md @@ -9,7 +9,7 @@ Status: implemented ## Problem 需要提供 UI 对接层,除已有 ACP/stdio基础版本外,还需要 Web(server) 、 Electron 、等其他产品 UI 形态。我们把这些形态统一称为 Client。希望有如下能力支持: -- 以 `dsh` 进程,同时支持 `dsh web`(启动) 和 `dsh -p`(headless) ,一个进程两种模式(设计预留) +- 以 `dsh` 进程,同时支持 `dsh web`(启动) 和 `dsh run`(headless) ,一个进程两种模式(设计预留) - 以与 `dsh web` 同构的 Web 技术形态,在 Electron 中启动 那么当前的工程代码需要稳定的分层职责模型,便于以后接入各类 client 形态。 @@ -29,7 +29,7 @@ Status: implemented - **fetch 到达插件包**(`ui-layout`、`ui-sidebar`、`ui-conversation`、`ui-trajectory`):双入口——根入口是 node 半边(空 `apply`,其存在是为了让 host Loader 管辖生命周期、让 web 插件注册表发现 package.json 的 `dshClient` 声明);实现住在 `src/client/` 下,经 `./client` 子路径发布(tsdown 闭包工厂 bundle)。跨插件消费 `/client` 只限类型;值层面的协作走 cordis 服务。 - `apps/` 作为对外导出的应用形态入口,可以由 Client / Host 混合组装。 - `apps/web`(`dsh-frontend`)是 vite 应用:`dsh-client-web` 导出的壳表面之上的一层薄 `main.ts`。 - - `apps/cli`(`@deepseek-ai/dsh`)做形态分发:`dsh web` = startHost + webserver + 构建出的 `dsh-frontend` dist;`dsh -p` = headless 进程内直调,零 HTTP。 + - `apps/cli`(`@deepseek-ai/dsh`)做形态分发:`dsh web` = startHost + webserver + 构建出的 `dsh-frontend` dist;`dsh run` = headless 进程内直调,零 HTTP。 - 将来的 Electron 形态经由 IPC fetch 载体复用同一套 web client 包。 ``` @@ -213,7 +213,7 @@ export type ResponseValue = | 子类 | 所在包 | doFetch | 用途 | |---|---|---|---| -| `InProcessApiClient` | apiproxy 本包 | 注入的 `{ fetch }` handler | **同构点**:`new InProcessApiClient(toFetchHandler(api))` 全程不过网络但真跑 wire 序列化/zod/SSE 帧——`dsh -p` headless 即协议第二真实消费者 | +| `InProcessApiClient` | apiproxy 本包 | 注入的 `{ fetch }` handler | **同构点**:`new InProcessApiClient(toFetchHandler(api))` 全程不过网络但真跑 wire 序列化/zod/SSE 帧——`dsh run` headless 即协议第二真实消费者 | | `WebApiClient` | dsh-client-connection | `globalThis.fetch` 上行 + 每逻辑流一条同源 WebSocket 下行 | 浏览器形态;物理边界见 [WebSocket 下行载体](2026-08-04-websocket-downlink-carrier.md) | | `FixtureApiClient` | dsh-client-connection | 不用(协议层覆写) | 无 server 的 UI 开发(`?fixture`):覆写 `callUnary`/`openMux`/`openHost`/`respond` 虚方法,自己就是假 server(帧 rpcId 由它 mint,语义自洽) | | (将来)IPC 桥子类 | apps/electron | IPC 序列化往返 | 仅换 doFetch,契约/基类零改 | diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml index eed6bee5f0..7fa37eeb8e 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.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-05-profile-plugin-bundles.md -2026-08-05-profile-plugin-bundles.md: 11a8ac3d4005371ca9596ba237aaf42a8e770dee -2026-08-05-profile-plugin-bundles.zh.md: 0e9ebf657ccb9d05967d90a935b356acf287a24c +2026-08-05-profile-plugin-bundles.md: b5bf5411d22ab99b598f667886b3c29ba8ee7b06 +2026-08-05-profile-plugin-bundles.zh.md: ae790028b5768c05c57acd27d7f68bdc4d612c11 diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md index 11a8ac3d40..b5bf5411d2 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md @@ -12,7 +12,7 @@ The `dsh` launcher hardcoded its compositions: `base.cordis.yml` + `web.cordis.y Everything becomes a **profile**: a directory `$DSH_HOME/profiles/` with a `package.json` (pnpm-managed out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list) and a user `cordis.patch.yml`. A **bundle** is an npm package declaring `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; the two manifest kinds live under distinct `dsh.profile` / `dsh.bundle` keys so a package.json states which role it plays. The tree composes over an empty root by applying each bundle's patch in `dsh.profile.bundles` order, then the user layer, then `--patch` overlays, then flag patches — one `applyEntryPatches` call, identical for boot, flag derivation, and `--dump-config`. -The shipped compositions became bundles: `@deepseek-ai/dsh-base` (the former base rows as one insert), `@deepseek-ai/dsh-web-app` (the former web overlay plus a runtime glue plugin that owns what used to be launcher code — frontend-dist resolution, the web-surface prompt section, bash runtime variables, the URL line), and `@deepseek-ai/dsh-headless` (a one-shot runner plugin over base + web-app). `dsh web` stays as an alias for `--profile web` carrying the Web flag family; `dsh --profile headless "task"` replaces `-p`; `dsh --config` is removed (its uses migrate to `--patch`). `dsh plugin --profile ` is a thin pnpm forwarder that initializes the profile and reconciles `dsh.profile.bundles` after `add`/`remove` (a bundle-less package warns and stays a plain dependency). +The shipped compositions became bundles: `@deepseek-ai/dsh-base` (the former base rows as one insert), `@deepseek-ai/dsh-web-app` (the former web overlay plus a runtime glue plugin that owns what used to be launcher code — frontend-dist resolution, the web-surface prompt section, bash runtime variables, the URL line), and `@deepseek-ai/dsh-headless` (a one-shot runner plugin over base + web-app). `dsh web` stays as an alias for `--profile web` carrying the Web flag family; `dsh run [--profile ] "task"` owns one-shot execution and defaults to the headless profile, while generic `dsh --profile ` boots without a task; `dsh --config` is removed (its uses migrate to `--patch`). `dsh plugin --profile ` is a thin pnpm forwarder that initializes the profile and reconciles `dsh.profile.bundles` after `add`/`remove` (a bundle-less package warns and stays a plain dependency). Resolution is two-anchored by construction: `dsh.profile.bundles` names resolve from the dsh installation first, then the profile directory — so in-box bundles always come from the same installation as the running `dsh` and pnpm never manages them — while bare plugin names in patch rows resolve through the profile directory's Node parent-walk into the maintained flat fallback `$DSH_HOME/profiles/node_modules` (one symlink per package the installation's app and bundles depend on, healed on every launch). diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md index 0e9ebf657c..ae790028b5 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md @@ -12,7 +12,7 @@ Status: implemented 一切都变成 **profile**:即目录 `$DSH_HOME/profiles/`,其中包含一个 `package.json`(pnpm 管理的树外插件 `dependencies`,加上 profile manifest `dsh.profile` 及其有序的 `bundles` 层列表)和一份用户 `cordis.patch.yml`。**组合包**(bundle)是声明了 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;两种 manifest 分别位于互不相同的 `dsh.profile` / `dsh.bundle` 键下,因此一份 package.json 能说明自己扮演哪种角色。配置树在空的根之上组合:按 `dsh.profile.bundles` 顺序应用每个组合包的 patch,然后是用户层,然后是 `--patch` overlay,最后是 flag patch——全部收敛为一次 `applyEntryPatches` 调用,启动、flag 派生与 `--dump-config` 使用完全相同的路径。 -已交付的组合改造成了组合包:`@deepseek-ai/dsh-base`(原有基础行合并为一次插入)、`@deepseek-ai/dsh-web-app`(原 web overlay,外加一个接管原启动器代码的运行时粘合插件——前端 dist 解析、web 表层提示词段落、bash 运行时变量、URL 行)、`@deepseek-ai/dsh-headless`(叠加在 base + web-app 之上的一次性 runner 插件)。`dsh web` 保留为携带 Web flag 家族的 `--profile web` 别名;`dsh --profile headless "task"` 取代 `-p`;`dsh --config` 被移除(其用途迁移到 `--patch`)。`dsh plugin --profile ` 是一层薄薄的 pnpm 转发器,负责初始化 profile,并在 `add`/`remove` 后调和 `dsh.profile.bundles`(没有组合包声明的包会给出警告,保持为普通依赖)。 +已交付的组合改造成了组合包:`@deepseek-ai/dsh-base`(原有基础行合并为一次插入)、`@deepseek-ai/dsh-web-app`(原 web overlay,外加一个接管原启动器代码的运行时粘合插件——前端 dist 解析、web 表层提示词段落、bash 运行时变量、URL 行)、`@deepseek-ai/dsh-headless`(叠加在 base + web-app 之上的一次性 runner 插件)。`dsh web` 保留为携带 Web flag 家族的 `--profile web` 别名;`dsh run [--profile ] "task"` 负责一次性执行,默认使用 headless profile,而通用的 `dsh --profile ` 只启动 profile,不携带任务;`dsh --config` 被移除(其用途迁移到 `--patch`)。`dsh plugin --profile ` 是一层薄薄的 pnpm 转发器,负责初始化 profile,并在 `add`/`remove` 后调和 `dsh.profile.bundles`(没有组合包声明的包会给出警告,保持为普通依赖)。 解析在构造上就是双锚点的:`dsh.profile.bundles` 中的名称先从 dsh 安装目录解析,再从 profile 目录解析——因此内置组合包始终来自与运行中 `dsh` 相同的安装,pnpm 从不管理它们——而 patch 行中的裸插件名称经 profile 目录的 Node 父目录逐级查找,落到受维护的扁平回退目录 `$DSH_HOME/profiles/node_modules`(安装目录的应用与各组合包所依赖的每个包各一个符号链接,每次启动时修复)。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.i18n.yaml index 59e98bc061..4752010ef6 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.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-03-cli-signal-shutdown-escalation.md -2026-08-03-cli-signal-shutdown-escalation.md: 2746b5784baad0f3b14258280cd56a621db07c15 -2026-08-03-cli-signal-shutdown-escalation.zh.md: 0bda83327d4cc8fe2edb61f8145a89138610901e +2026-08-03-cli-signal-shutdown-escalation.md: 7c9715c37ee57be9fa0f67af0c19f0bfa84845da +2026-08-03-cli-signal-shutdown-escalation.zh.md: f3485edc9e453c0b774f442bfce6d678d63f2224 diff --git a/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.md b/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.md index 2746b5784b..7c9715c37e 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.md +++ b/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.md @@ -6,9 +6,9 @@ English | [中文](2026-08-03-cli-signal-shutdown-escalation.zh.md) ## Problem -The default telemetry mount added SIGINT/SIGTERM handlers to `dsh web` and `dsh -p` so process exit could drain the Cordis tree instead of dropping queued telemetry. Each handler used a one-way boolean latch and exited only after `ctx.fiber.dispose()` settled. Headless normal completion also awaited that disposal without a bound. +The default telemetry mount added SIGINT/SIGTERM handlers to `dsh web` and the headless command (now `dsh run`) so process exit could drain the Cordis tree instead of dropping queued telemetry. Each handler used a one-way boolean latch and exited only after `ctx.fiber.dispose()` settled. Headless normal completion also awaited that disposal without a bound. -A user then reproduced `dsh -p` hanging immediately after the observation URL and ignoring repeated `Ctrl+C`; `DSH_TELEMETRY_DISABLED=1` removed the hang, while a standalone Node handler in the same Linux sandbox received SIGINT. This isolated the pending disposer to telemetry rather than terminal signal forwarding. OTel's `BatchLogRecordProcessor.shutdown()` awaits `exporter.forceFlush()` before the `exportTimeoutMillis`-bounded completion promise, and the OTLP exporter's `forceFlush()` waits directly on its in-flight HTTP Promise. A proxy/sandbox connection that never obtains a socket can therefore leave provider shutdown pending despite both configured SDK timeouts. +A user then reproduced the headless command hanging immediately after the observation URL and ignoring repeated `Ctrl+C`; `DSH_TELEMETRY_DISABLED=1` removed the hang, while a standalone Node handler in the same Linux sandbox received SIGINT. This isolated the pending disposer to telemetry rather than terminal signal forwarding. OTel's `BatchLogRecordProcessor.shutdown()` awaits `exporter.forceFlush()` before the `exportTimeoutMillis`-bounded completion promise, and the OTLP exporter's `forceFlush()` waits directly on its in-flight HTTP Promise. A proxy/sandbox connection that never obtains a socket can therefore leave provider shutdown pending despite both configured SDK timeouts. The latch then turned that telemetry defect into an unkillable CLI: normal completion was already awaiting the single-shot root disposal; the first SIGINT joined the same pending disposal and set the signal latch; later SIGINTs returned at the latch, so the process had no remaining escape. A signal received before normal completion had the same unbounded wait. Web used the same latch shape. diff --git a/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.zh.md b/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.zh.md index 0bda83327d..f3485edc9e 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.zh.md @@ -6,9 +6,9 @@ ## 问题 -默认挂载遥测后,`dsh web` 与 `dsh -p` 新增了 SIGINT/SIGTERM 处理器,使进程退出时可以排空 Cordis 插件树,而不是丢弃排队中的遥测数据。每个处理器都使用单向布尔闩锁(latch),并且只有在 `ctx.fiber.dispose()` 结算后才退出。headless 正常完成时同样会无界等待整棵树执行 dispose(资源释放)。 +默认挂载遥测后,`dsh web` 与 headless 命令(现为 `dsh run`)新增了 SIGINT/SIGTERM 处理器,使进程退出时可以排空 Cordis 插件树,而不是丢弃排队中的遥测数据。每个处理器都使用单向布尔闩锁(latch),并且只有在 `ctx.fiber.dispose()` 结算后才退出。headless 正常完成时同样会无界等待整棵树执行 dispose(资源释放)。 -随后有用户复现,`dsh -p` 在打印观察 URL 后立即卡死,重复按 `Ctrl+C` 也没有反应;设置 `DSH_TELEMETRY_DISABLED=1` 后不再卡死,而同一 Linux 沙箱中的独立 Node 信号处理器能够收到 SIGINT。这将待结算的 disposer 定位到遥测,而非终端信号转发。OTel 的 `BatchLogRecordProcessor.shutdown()` 会先等待 `exporter.forceFlush()`,再进入受 `exportTimeoutMillis` 限制的完成 promise;OTLP 导出器的 `forceFlush()` 则直接等待正在进行的 HTTP Promise。因此,代理/沙箱连接始终无法取得 socket 时,即使已经配置两项 SDK 超时,也会让提供方关闭一直待结算。 +随后有用户复现,headless 命令在打印观察 URL 后立即卡死,重复按 `Ctrl+C` 也没有反应;设置 `DSH_TELEMETRY_DISABLED=1` 后不再卡死,而同一 Linux 沙箱中的独立 Node 信号处理器能够收到 SIGINT。这将待结算的 disposer 定位到遥测,而非终端信号转发。OTel 的 `BatchLogRecordProcessor.shutdown()` 会先等待 `exporter.forceFlush()`,再进入受 `exportTimeoutMillis` 限制的完成 promise;OTLP 导出器的 `forceFlush()` 则直接等待正在进行的 HTTP Promise。因此,代理/沙箱连接始终无法取得 socket 时,即使已经配置两项 SDK 超时,也会让提供方关闭一直待结算。 闩锁随后把这个遥测缺陷变成无法终止的 CLI(命令行界面):正常完成流程已经在等待单次根级 dispose;第一次 SIGINT 会加入同一个待结算的 dispose,并设置信号闩锁;后续 SIGINT 在闩锁处直接返回,因此进程再无退出途径。正常完成之前收到信号时,同样会陷入无界等待。Web 使用的闩锁结构与此相同。 diff --git a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.i18n.yaml new file mode 100644 index 0000000000..8d5ec1c9f6 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.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/feature/2026-08-08-dsh-run-headless-command.md +2026-08-08-dsh-run-headless-command.md: aac2a473760509626d315df8d57eb405eb547abf +2026-08-08-dsh-run-headless-command.zh.md: d71d2a34addf1c64b8cb37c54117be5b9c643370 diff --git a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.md b/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.md new file mode 100644 index 0000000000..aac2a47376 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.md @@ -0,0 +1,39 @@ +# Agent Note: `dsh run` owns one-shot headless execution + +Status: implemented + +English | [中文](2026-08-08-dsh-run-headless-command.zh.md) + +## Problem + +The product launcher attached optional task text to its generic profile boot: `dsh --profile headless "task"`. That made one argv shape mean either a long-lived profile or a one-shot run according to a row discovered only after composition. The parser's `ProfileInvocation` carried optional task state, help presented a profile implementation detail as the user command, and a custom profile could accept a task only through the same overloaded root. + +The former `dsh -p` spelling was already absent from the parser, so restoring it or detecting it specially would add compatibility machinery to a pre-release interface. A separate application-file proposal also used the `run` verb, leaving two incompatible owners for one top-level command. + +## Decision + +One-shot execution owns an explicit grammar: + +```text +dsh run [--profile ] [--patch ...] +``` + +`--profile` defaults to `headless` and remains available for custom one-shot compositions. `--patch` is repeatable and occupies the existing overlay layer. Commander joins the variadic task arguments with spaces and rejects a missing or blank task before boot. + +`RunInvocation` is a separate `DshInvocation` member. The generic profile invocation no longer carries task text, and its root command accepts no positional arguments. Both dispatch paths call the existing deep `runProfile` module: `profile` omits `task`, while `run` supplies it. There is no shallow `run.ts` forwarding module and no alias, warning, or custom detector for former spellings; they fail through the ordinary Commander grammar. A one-shot profile without `headless-runner` still fails through the existing composed-row check, while booting a profile that contains that row without a task points to `dsh run --profile ""`. + +The `run` verb belongs to one-shot task execution. Launching an application file must choose another command name; two top-level meanings selected by positional shape would recreate the ambiguity this command removes. + +The runner's user-visible contract stays the same: a fresh persisted session, browser observation URL on stderr, final assistant text on stdout, completed/non-completed exit mapping, and bounded signal shutdown. The product-level keyless acceptance exposed that the in-process mux consumer could lag the same-process `agent/status: idle` notification and derive output before reading the final frames. The idle notification now captures the authoritative final session sequence, and the runner waits until the ordered mux reaches that boundary (or the stream ends) before deriving text and exit reason. This enforces the existing idle-to-idle contract without adding a wire field or a timing delay. + +## Alternatives considered + +- **Keep task text on `dsh --profile`.** Rejected because profile boot and one-shot execution remain one grammar whose meaning depends on a late composition check. +- **Preserve `dsh -p` or the positional profile form as aliases.** Rejected under the pre-release stance: compatibility branches would outlive the interface they were meant to retire. +- **Make `--profile headless` mandatory under `run`.** Rejected because the shipped one-shot surface should have the shortest canonical spelling, while optional `--profile` preserves plugin-defined one-shot compositions. +- **Give `dsh run` to application-file launch and choose another headless verb.** Rejected because `run` describes executing a task through the harness; application-file ownership would make the product's primary one-shot command less direct and collide with custom one-shot profiles. +- **Add `apps/cli/src/run.ts`.** Rejected because it would only forward to `runProfile`, splitting command ownership without hiding any complexity. + +## Consequences + +This is an intentional breaking CLI change. Documentation, help, parser tests, built-bin acceptance, PTY shutdown coverage, and the assembled keyless snapshot use `dsh run`. Existing custom one-shot profiles keep working through `--profile`; long-lived profiles and config dumps retain their existing root grammar. The competing application-file command must be renamed and rebased separately rather than sharing or overloading `run`. diff --git a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.zh.md b/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.zh.md new file mode 100644 index 0000000000..d71d2a34ad --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.zh.md @@ -0,0 +1,39 @@ +# Agent Note: `dsh run` 负责一次性 headless 执行 + +Status: implemented + +[English](2026-08-08-dsh-run-headless-command.md) | 中文 + +## 问题 + +产品启动器过去把可选任务文本挂在通用 profile 启动命令上:`dsh --profile headless "task"`。于是,同一种 argv 形态会表示常驻 profile 或一次性运行,具体含义取决于组合完成后才发现的配置行。解析器的 `ProfileInvocation` 携带可选任务状态,帮助信息把 profile 的实现细节呈现为用户命令,自定义 profile 也只能通过同一个过载的根命令接收任务。 + +解析器中已经没有原来的 `dsh -p` 写法,因此恢复该写法或加入特殊检测,会给预发布接口增加兼容机制。另一个应用文件提案也使用 `run` 动词,使同一个顶层命令同时归属两个互不兼容的功能。 + +## 决策 + +一次性执行采用明确语法: + +```text +dsh run [--profile ] [--patch ...] +``` + +`--profile` 默认为 `headless`,同时保留对自定义一次性组合的支持。`--patch` 可重复使用,并沿用既有 overlay 层的位置。Commander 用空格拼接可变数量的任务参数,并在启动前拒绝缺失或空白任务。 + +`RunInvocation` 是单独的 `DshInvocation` 成员。通用 profile 调用不再携带任务文本,其根命令也不接受位置参数。两条分派路径都调用已有的深层 `runProfile` 模块:`profile` 省略 `task`,`run` 则提供该字段。实现中没有只负责转发的浅层 `run.ts` 模块,也没有面向旧写法的别名、警告或自定义检测器;旧写法会按普通 Commander 语法失败。缺少 `headless-runner` 的一次性 profile 仍会触发既有的组合行检查;如果启动的 profile 包含该行却未提供任务,错误会指向 `dsh run --profile ""`。 + +`run` 动词只负责一次性任务执行。应用文件启动必须选择其他命令名;如果让两个顶层含义由位置参数形态决定,就会重新引入本命令消除的歧义。 + +运行器面向用户的契约保持不变:创建新的持久化会话,在 stderr 打印浏览器观察 URL,在 stdout 打印最终 assistant 文本,将完成/未完成映射为退出状态,并执行有界的信号关闭。产品级无密钥验收用例发现,进程内 mux 消费方可能落后于同进程的 `agent/status: idle` 通知,在读到最终帧之前就生成输出。idle 通知现在会捕获权威的会话最终事件序号,运行器则等待有序 mux 到达该边界(或流结束),再生成文本和退出原因。这一机制在不增加 wire 字段或定时延迟的前提下,落实了既有的 idle-to-idle 契约。 + +## 考虑过的替代方案 + +- **把任务文本保留在 `dsh --profile` 上。** 不予采纳:profile 启动和一次性执行仍共用同一套语法,其含义取决于较晚发生的组合检查。 +- **保留 `dsh -p` 或位置参数 profile 形式作为别名。** 不予采纳:根据预发布立场,这些兼容分支会比本应退役的接口存续更久。 +- **要求在 `run` 下必须指定 `--profile headless`。** 不予采纳:已交付的一次性接口应采用最短的规范写法,同时用可选的 `--profile` 保留插件定义的一次性组合。 +- **把 `dsh run` 交给应用文件启动,并为 headless 选择另一个动词。** 不予采纳:`run` 描述的是通过 harness 执行任务;若归应用文件所有,产品的主要一次性命令会更不直接,并与自定义一次性 profile 冲突。 +- **新增 `apps/cli/src/run.ts`。** 不予采纳:它只会转发到 `runProfile`,拆分命令归属,却没有隐藏任何复杂度。 + +## 后果 + +这是一次有意为之的 CLI(命令行界面)破坏性变更。文档、帮助信息、解析器测试、构建后二进制验收、PTY 关闭覆盖和组装应用的无密钥快照都使用 `dsh run`。现有自定义一次性 profile 可继续通过 `--profile` 工作;常驻 profile 和配置 dump 保留既有的根命令语法。与之竞争的应用文件命令必须单独改名并 rebase,不得共享或重载 `run`。 diff --git a/.agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.i18n.yaml index 8ff1af7e8e..ee43f9465c 100644 --- a/.agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.md 2026-08-03-explicit-config-dsh-entrypoint.md: e0d1e954d9cef472ea59345a3d2ef5a67bd03ae8 -2026-08-03-explicit-config-dsh-entrypoint.zh.md: b5b464e3b45a6f3909bbf087f7005ad3f819424a +2026-08-03-explicit-config-dsh-entrypoint.zh.md: 614c2d8600731d85c83d6559bc577350da25e872 diff --git a/.agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.zh.md b/.agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.zh.md index b5b464e3b4..614c2d8600 100644 --- a/.agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.zh.md +++ b/.agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.zh.md @@ -1,4 +1,4 @@ -# Agent Note:显式配置的 dsh 入口 +# Agent Note: 显式配置的 dsh 入口 Status: implemented diff --git a/README.i18n.yaml b/README.i18n.yaml index 0a7c3e49fe..c4ad1f0fd9 100644 --- a/README.i18n.yaml +++ b/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 README.md -README.md: d8d3e767d5a9805f34f4df57a5b1f8ff7fdaa955 -README.zh.md: 89abf8d817deeed2bf4416035790c8696c8c8e33 +README.md: 64f06c0aec0905fa7deabbec0deea61e1c7a40d4 +README.zh.md: fee03118926028833c828809764ebb5f6375259e diff --git a/README.md b/README.md index d8d3e767d5..64f06c0aec 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ The [CLI contract](apps/cli/README.md#profiles) describes profile layout, layer Run one task, print the final answer, and exit: ```sh -dsh --profile headless "summarize this workspace" +dsh run "summarize this workspace" ``` ### Automation and SDKs diff --git a/README.zh.md b/README.zh.md index 89abf8d817..fee0311892 100644 --- a/README.zh.md +++ b/README.zh.md @@ -56,7 +56,7 @@ profile 布局、层语义与配置输出命令详见 [CLI(命令行界面) 运行一项任务,打印最终答案后退出: ```sh -dsh --profile headless "summarize this workspace" +dsh run "summarize this workspace" ``` ### 自动化与 SDK diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index cbc74b6d0a..801560e7d7 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: f50f26ec5de54e094e17221f7cd355483483754e -README.zh.md: 242e64a0c42064b9f0b7621e665e85fe44523fe5 +README.md: 12108fcbff4e649d0bcb3e01e688fa334ab91b14 +README.zh.md: 9518feed1d5d40c3e5ec2d346b929118ccc08810 diff --git a/apps/cli/README.md b/apps/cli/README.md index f50f26ec5d..12108fcbff 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -9,11 +9,11 @@ The `dsh` command is the product launcher for profiles: ordered stacks of plugin | Command | Purpose | |---|---| | `dsh --profile ` | Boot the named profile under `$DSH_HOME/profiles/`. | -| `dsh --profile headless "task"` | Run one fresh persisted session, print the final answer, and exit. | +| `dsh run [--profile ] [--patch ...] "task"` | Run one fresh persisted session, print the final answer, and exit; the profile defaults to `headless`. | | `dsh web` | Alias of `--profile web` with the Web flag family (`--host`, `--port`, `--dev`, ...). | | `dsh plugin --profile ` | Manage a profile's plugins by forwarding to pnpm in the profile directory. | -The invoking directory is the default workspace root. The `web` and `headless` profiles auto-initialize on first use from shipped templates; any other profile must be created through `dsh plugin`. +The invoking directory is the default workspace root. `dsh run` requires non-blank task text and the selected profile must mount the `headless-runner` row; `--profile` preserves custom one-shot profiles. The `web` and `headless` profiles auto-initialize on first use from shipped templates; any other profile must be created through `dsh plugin`. ## Profiles diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 242e64a0c4..9518feed1d 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -9,11 +9,11 @@ | 命令 | 用途 | |---|---| | `dsh --profile ` | 启动位于 `$DSH_HOME/profiles/` 的指定 profile。 | -| `dsh --profile headless "task"` | 运行一个新的持久化会话,打印最终答案并退出。 | +| `dsh run [--profile ] [--patch ...] "task"` | 运行一个新的持久化会话,打印最终答案并退出;profile 默认为 `headless`。 | | `dsh web` | `--profile web` 的别名,附带 Web flag 系列(`--host`、`--port`、`--dev` 等)。 | | `dsh plugin --profile ` | 通过在 profile 目录中转发给 pnpm 来管理该 profile 的插件。 | -调用目录是默认 workspace 根目录。`web` 和 `headless` profile 在首次使用时会从随附模板自动初始化;其他任何 profile 都必须通过 `dsh plugin` 创建。 +调用目录是默认 workspace 根目录。`dsh run` 要求任务文本非空白,且所选 profile 必须挂载 `headless-runner` 行;`--profile` 保留对自定义一次性 profile 的支持。`web` 和 `headless` profile 在首次使用时会从随附模板自动初始化;其他任何 profile 都必须通过 `dsh plugin` 创建。 ## Profile diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index e64141c31d..27e391320a 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: c7c7b2aa231d4c9f4b3fbf31663237c8457eb051 -README.zh.md: 5439aa78b74415c8e6264d21f5c52e5cee5b38ee +README.md: 496cecdb64e3254a2a77690f55f760b4cd90b521 +README.zh.md: 4673bf764347307a9b91e2a5474a8439cf67b481 diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index c7c7b2aa23..496cecdb64 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -This reference defines the profile, web-alias, plugin-management, and config-dump command modes. Argv is parsed once through [`src/args.ts`](../src/args.ts), and [`src/bin.ts`](../src/bin.ts) dynamically imports only the selected runner. +This reference defines the profile, one-shot run, web-alias, plugin-management, and config-dump command modes. Argv is parsed once through [`src/args.ts`](../src/args.ts), and [`src/bin.ts`](../src/bin.ts) dynamically imports only the selected runner. ## Profile boot @@ -12,7 +12,7 @@ Bundle names resolve from the dsh installation first, then from the profile dire The `web` and `headless` profiles auto-initialize from shipped templates on first use (`web`: base + web-app; `headless`: base + web-app + headless). Any other missing profile fails loud with a hint to run `dsh plugin --profile add `. -A positional task (`dsh --profile headless "run the tests"`) requires the composition to mount the one-shot runner row (`headless-runner`); the launcher patches the task text into that row, the runner drives one fresh persisted session through the in-process API carrier, prints the final assistant text on stdout, and exits 0 on a completed turn, else 1. The session's Web host runs on an OS-assigned port and is announced on stderr, so the run is observable in a browser. +Profile boot accepts no positional task. A profile that mounts the one-shot runner row (`headless-runner`) therefore fails loud with the canonical `dsh run --profile ""` command instead of reaching the row's raw required-field error. Inspect the composed tree without booting it: @@ -23,6 +23,12 @@ dsh --profile web --patch ./extra.yml --dump-config `--dump-default-config` prints only the bundle layers; `--dump-config` adds the profile's `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml`, and `--patch` overlays. Both print provenance comments per layer; `!!js` expressions remain unevaluated, and unmatched patch targets are reported on stderr. +## One-shot run + +`dsh run [--profile ] [--patch ...] ` joins the task arguments with spaces, rejects a missing or blank task, and defaults `--profile` to `headless`. Repeatable `--patch` overlays occupy the same layer position as profile-boot overlays. A custom selected profile must mount `headless-runner`; otherwise launch fails before boot with a diagnostic naming that missing row. + +The launcher patches the task text into the runner row, which drives one fresh persisted session through the in-process API carrier, prints the final assistant text on stdout, and exits 0 on a completed turn, else 1. At the idle boundary, the runner waits until its mux consumer has observed the session's final event sequence before deriving that output and exit reason. The session's Web host runs on an OS-assigned port and is announced on stderr, so the run is observable in a browser. + ## Plugin management `dsh plugin --profile ` initializes the profile when missing (shipped template, or `@deepseek-ai/dsh-base` alone for other names), then forwards `` to `pnpm` with the profile directory as working directory — `add`, `remove`, `why`, `update`, and every other pnpm verb work unchanged; pnpm must be on PATH. Relative path specs (`.`, `../plugin`, and their `file:`/`link:` forms) are anchored to the invoking directory first, so `add .` from a plugin checkout installs that checkout, not the profile. After every successful run, `dsh.profile.bundles` is reconciled against the installed state: each dependency resolving to a package whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` joins the layer stack (so an `update` that gains the declaration activates it), a bundle-less dependency stays plain with a one-time warning, and a removed dependency leaves the stack. diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index 5439aa78b7..4673bf7643 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -本参考定义 profile、web 别名、插件管理和配置 dump 命令模式。参数由 [`src/args.ts`](../src/args.ts) 统一解析,[`src/bin.ts`](../src/bin.ts) 只动态导入选中的运行器。 +本参考定义 profile、一次性运行、web 别名、插件管理和配置 dump 命令模式。参数由 [`src/args.ts`](../src/args.ts) 统一解析,[`src/bin.ts`](../src/bin.ts) 只动态导入选中的运行器。 ## Profile 启动 @@ -12,7 +12,7 @@ `web` 和 `headless` profile 首次使用时会从随附模板自动初始化(`web`:base + web-app;`headless`:base + web-app + headless)。其他缺失的 profile 会显式报错,并提示运行 `dsh plugin --profile add `。 -位置参数任务(`dsh --profile headless "run the tests"`)要求组合挂载一次性运行器行(`headless-runner`);启动器把任务文本 patch 进该行,运行器通过进程内 API 载体驱动一个全新的持久化会话,在 stdout 打印最终 assistant 文本,并在轮次完成时以 0 退出,否则以 1 退出。会话的 Web 宿主运行在 OS 分配的端口上并公布到 stderr,因此该次运行可在浏览器中观察。 +Profile 启动不接受位置参数任务。因此,挂载了一次性运行器行(`headless-runner`)的 profile 会显式报错,并提示规范命令 `dsh run --profile ""`,而不会触发该行原始的必填字段错误。 可在不启动的情况下检查组合出的配置树: @@ -23,6 +23,12 @@ dsh --profile web --patch ./extra.yml --dump-config `--dump-default-config` 只打印组合包各层;`--dump-config` 额外加上 profile 的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml` 和 `--patch` overlay。两者都会按层打印来源注释;`!!js` 表达式保持未求值,找不到目标的 patch 会报告到 stderr。 +## 一次性运行 + +`dsh run [--profile ] [--patch ...] ` 会用空格拼接任务参数,拒绝缺失或空白任务,并让 `--profile` 默认为 `headless`。可重复使用的 `--patch` overlay 与 profile 启动的 overlay 位于同一层。所选的自定义 profile 必须挂载 `headless-runner`;否则启动器会在启动前失败,并在诊断中指明缺少该行。 + +启动器把任务文本 patch 进运行器行,运行器再通过进程内 API 载体驱动一个全新的持久化会话,在 stdout 打印最终 assistant 文本,并在轮次完成时以 0 退出,否则以 1 退出。到达 idle 边界时,运行器会等到 mux 消费方观察到会话的最终事件序号,再生成输出与退出原因。会话的 Web 宿主运行在 OS 分配的端口上并公布到 stderr,因此该次运行可在浏览器中观察。 + ## 插件管理 `dsh plugin --profile ` 在 profile 缺失时先初始化它(有随附模板的用模板,其他名称只装 `@deepseek-ai/dsh-base`),然后以 profile 目录为工作目录,把 `` 转发给 `pnpm`:`add`、`remove`、`why`、`update` 及其他所有 pnpm 子命令都照常可用;pnpm 必须在 PATH 上。相对路径 spec(`.`、`../plugin` 及其 `file:`/`link:` 形式)会先锚定到调用目录,因此在插件 checkout 中执行 `add .` 安装的是该 checkout,而不是 profile。每次成功运行后,`dsh.profile.bundles` 都会与已安装状态对齐:每个解析到 manifest 中声明了 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的包的依赖加入层栈(因此让包获得该声明的 `update` 会将其激活),没有组合包声明的依赖保持为普通依赖并给出一次性警告,已移除的依赖则退出层栈。 diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index 310b5b03a2..0b72f76c6a 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -1,10 +1,10 @@ /** * Commander adapter for the `dsh` command-line entry. The default command * boots a named profile (`--profile `), optionally with extra `--patch` - * overlays and a positional task (one-shot mode for profiles mounting the - * headless runner). `web` is a hardcoded alias for `--profile web` that adds - * the Web flag family; `plugin` manages a profile's plugin dependencies by - * forwarding to pnpm. Commander owns help, version, and parse errors. + * overlays. `run` owns one-shot task execution, defaulting to the headless + * profile; `web` is a hardcoded alias for `--profile web` that adds the Web + * flag family; `plugin` manages a profile's plugin dependencies by forwarding + * to pnpm. Commander owns help, version, and parse errors. * @module @deepseek-ai/dsh/args */ @@ -16,8 +16,16 @@ interface ProfileInvocation { profile: string /** Extra patch-list overlays applied after the profile's own layer, in argv order. */ patches: string[] - /** Positional task text joined by spaces; non-empty only for one-shot runs. */ - task?: string +} + +/** Run one task through a profile mounting the headless runner. */ +interface RunInvocation { + mode: 'run' + profile: string + /** Extra patch-list overlays applied after the profile's own layer, in argv order. */ + patches: string[] + /** Non-blank task text joined from the variadic positional arguments. */ + task: string } /** Print a composed profile tree and exit without booting. */ @@ -54,7 +62,7 @@ interface PluginInvocation { } /** The resolved `dsh` invocation. Help, version, and errors exit inside {@link parseDshArgs}. */ -export type DshInvocation = ProfileInvocation | DumpConfigInvocation | WebInvocation | PluginInvocation +export type DshInvocation = ProfileInvocation | RunInvocation | DumpConfigInvocation | WebInvocation | PluginInvocation /** Raw web-subcommand options straight from Commander. */ interface WebOptions { @@ -68,6 +76,12 @@ interface WebOptions { dumpDefaultConfig?: boolean } +/** Raw run-subcommand options straight from Commander. */ +interface RunOptions { + profile?: string + patch?: string[] +} + /** * Repeatable single-value collector: `--patch a.yml --patch b.yml`. Never * variadic — a variadic `--patch` would swallow a following positional task. @@ -90,19 +104,19 @@ export function parseDshArgs(argv: readonly string[], version: string): DshInvoc .addHelpText('after', ` Examples: dsh --profile web boot the web profile (same as: dsh web) - dsh --profile headless "run the tests" answer one task, print the result, and exit + dsh run "run the tests" answer one task, print the result, and exit + dsh run --profile custom "run the tests" run one task through a custom one-shot profile dsh --profile tui --patch ./extra.yml boot a custom profile with one extra overlay dsh plugin --profile tui add install a plugin into the tui profile dsh web --port 8080 the web alias with its flag family `) .exitOverride() .enablePositionalOptions() - .argument('[task...]', 'one-shot task text for profiles mounting the headless runner') .option('--profile ', 'the profile under $DSH_HOME/profiles to boot') .option('--patch ', 'extra patch-list overlay applied after the profile layer (repeatable)', collect) .option('--dump-config', 'print the composed profile tree and exit') .option('--dump-default-config', 'print the profile tree without its user layer or --patch overlays and exit') - .action((task: string[], options: { + .action((options: { profile?: string patch?: string[] dumpConfig?: boolean @@ -116,7 +130,6 @@ Examples: if (options.dumpConfig === true && options.dumpDefaultConfig === true) { program.error('error: --dump-config and --dump-default-config are mutually exclusive') } - if (task.length > 0) program.error('error: --dump-config/--dump-default-config take no task') const defaultOnly = options.dumpDefaultConfig === true if (defaultOnly && patches.length > 0) { program.error('error: --dump-default-config prints the bundle layers and takes no --patch') @@ -124,12 +137,7 @@ Examples: resolved = { mode: 'dump-config', profile, defaultOnly, patches } return } - resolved = { - mode: 'profile', - profile, - patches, - ...task.length > 0 ? { task: task.join(' ') } : {}, - } + resolved = { mode: 'profile', profile, patches } }) /** Reject parent options that crossed a subcommand boundary. */ @@ -146,6 +154,22 @@ Examples: } } + const run = program.command('run').description('run one task through a profile mounting the headless runner') + run + .option('--profile ', 'one-shot profile under $DSH_HOME/profiles', 'headless') + .option('--patch ', 'extra patch-list overlay applied after the profile layer (repeatable)', collect) + .argument('', 'task text') + .action((task: string[], options: RunOptions) => { + rejectParentOptions('run') + const profile = options.profile ?? 'headless' + if (profile === '') program.error('error: --profile needs a name') + const patches = options.patch ?? [] + if (patches.includes('')) program.error('error: --patch needs a path') + const joined = task.join(' ') + if (joined.trim() === '') program.error('error: run needs a non-blank task') + resolved = { mode: 'run', profile, patches, task: joined } + }) + const web = program.command('web').description('serve the browser UI (alias of --profile web) on the configured host and port') web .option('--patch ', 'extra patch-list overlay applied after the profile layer (repeatable)', collect) diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts index 4a209b2796..b332a64615 100644 --- a/apps/cli/src/bin.ts +++ b/apps/cli/src/bin.ts @@ -33,7 +33,16 @@ switch (invocation.mode) { environment: loadLayeredEnv('dsh'), profile: invocation.profile, patchFiles: invocation.patches, - ...invocation.task !== undefined && { task: invocation.task }, + }) + break + } + case 'run': { + const { runProfile } = await import('./profile-boot.ts') + await runProfile({ + environment: loadLayeredEnv('dsh'), + profile: invocation.profile, + patchFiles: invocation.patches, + task: invocation.task, }) break } diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index 742bf29db4..4730ec7073 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -47,7 +47,7 @@ export const INSTALL_ANCHOR = fileURLToPath(new URL('../package.json', import.me /** The session-telemetry row id the DSH_TELEMETRY_DISABLED switch targets. */ const TELEMETRY_ROW_ID = 'telemetry-otel' -/** The one-shot runner row a positional task requires and configures. */ +/** The one-shot runner row a `dsh run` task requires and configures. */ const HEADLESS_ROW_ID = 'headless-runner' /** The empty root entry list every profile tree patches over. */ @@ -160,7 +160,7 @@ export interface RunProfileOptions { patchFiles: readonly string[] /** Launcher hook turning the pre-flag composed rows into flag patches (the web alias's flag family). */ deriveFlagPatches?: (rows: ProfileRows) => PatchOptions[] - /** One-shot task text; requires the composition to mount the headless runner row. */ + /** `dsh run` task text; requires the composition to mount the headless runner row. */ task?: string /** Surface setup registered after Loader installation and before any config-tree entry mounts. */ prepare?: (ctx: Context, rows: ProfileRows) => Promise | void @@ -190,7 +190,7 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con // error naming no fix. throw new Error( `dsh: profile ${JSON.stringify(options.profile)} mounts the one-shot runner and needs a task: ` - + `dsh --profile ${options.profile} ""`, + + `dsh run --profile ${options.profile} ""`, ) } diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts index 93bfb62cc6..c9b3dc18f1 100644 --- a/apps/cli/tests/args.spec.ts +++ b/apps/cli/tests/args.spec.ts @@ -21,12 +21,16 @@ function exitCode(argv: string[]): number { afterEach(() => { vi.restoreAllMocks() }) describe('parseDshArgs', () => { - it('routes profile boots, one-shot tasks, and the web alias', () => { + it('routes profile boots, one-shot runs, and the web alias', () => { expect(parse(['--profile', 'tui'])).toEqual({ mode: 'profile', profile: 'tui', patches: [] }) - expect(parse(['--profile', 'headless', 'run', 'the', 'tests'])) - .toEqual({ mode: 'profile', profile: 'headless', patches: [], task: 'run the tests' }) expect(parse(['--profile', 'tui', '--patch', 'a.yml', '--patch', 'b.yml'])) .toEqual({ mode: 'profile', profile: 'tui', patches: ['a.yml', 'b.yml'] }) + expect(parse(['run', 'run', 'the', 'tests'])) + .toEqual({ mode: 'run', profile: 'headless', patches: [], task: 'run the tests' }) + expect(parse(['run', '--profile', 'custom', '--patch', 'a.yml', '--patch', 'b.yml', 'run', 'the', 'tests'])) + .toEqual({ mode: 'run', profile: 'custom', patches: ['a.yml', 'b.yml'], task: 'run the tests' }) + expect(parse(['run', '--', '--profile', 'is', 'task', 'text'])) + .toEqual({ mode: 'run', profile: 'headless', patches: [], task: '--profile is task text' }) expect(parse(['web'])).toEqual({ mode: 'web', dev: false, patches: [] }) expect(parse(['web', '--patch', 'web.yml'])).toEqual({ mode: 'web', dev: false, patches: ['web.yml'] }) expect(parse(['web', '--host', '0.0.0.0', '--port', '8080', '--dev', '--workspace-root', '/w'])) @@ -65,6 +69,13 @@ describe('parseDshArgs', () => { expect(exitCode(['tui'])).toBe(1) // a bare word is a task without --profile expect(exitCode(['--config', 'c.yml'])).toBe(1) // removed expect(exitCode(['-p', 'task'])).toBe(1) // removed + expect(exitCode(['--profile', 'headless', 'task'])).toBe(1) // tasks belong to `run` + expect(exitCode(['run'])).toBe(1) + expect(exitCode(['run', ''])).toBe(1) + expect(exitCode(['run', '--profile', '', 'task'])).toBe(1) + expect(exitCode(['run', '--patch=', 'task'])).toBe(1) + expect(exitCode(['--profile', 'headless', 'run', 'task'])).toBe(1) + expect(exitCode(['--patch', 'parent.yml', 'run', 'task'])).toBe(1) expect(exitCode(['--profile', ''])).toBe(1) expect(exitCode(['--profile', 'x', '--patch='])).toBe(1) expect(exitCode(['--dump-config'])).toBe(1) @@ -90,6 +101,7 @@ describe('parseDshArgs', () => { it('exits 0 for help and version', () => { expect(exitCode(['--help'])).toBe(0) + expect(exitCode(['run', '--help'])).toBe(0) expect(exitCode(['--version'])).toBe(0) }) }) diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index 20ed3fb160..a42780581f 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -182,14 +182,56 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', const help = await runBuiltBin(['--help']) expect(help.code).toBe(0) expect(help.stdout).toContain('dsh --profile web') + expect(help.stdout).toContain('dsh run "run the tests"') expect(help.stdout).toContain('dsh plugin --profile') expect(help.stdout).not.toMatch(/^\s+(?:tui|meta|upgrade)\b/mu) - for (const removed of [['tui'], ['--config', 'x.yml'], ['-p', 'task']]) { + for (const removed of [['tui'], ['--config', 'x.yml'], ['-p', 'task'], ['--profile', 'headless', 'task']]) { const result = await runBuiltBin(removed) expect(result.code).toBe(1) } }, 30_000) + it('prints run help without initializing the selected profile', async () => { + const parent = mkdtempSync(join(tmpdir(), 'dsh-run-help-')) + const home = join(parent, 'not-created') + try { + const result = await runBuiltBin(['run', '--help'], { DSH_HOME: home }) + expect(result.code).toBe(0) + expect(result.stderr).toBe('') + expect(result.stdout).toContain('Usage: dsh run [options] ') + expect(existsSync(home)).toBe(false) + } finally { + rmSync(parent, { recursive: true, force: true }) + } + }) + + it('runs the default headless profile through the published run command', async () => { + const apiKey = 'built-dsh-run-key' + const server = await startMockLlmServer({ + sequence: ['success'], + apiKey, + successText: 'published dsh run reached the mock', + }) + const home = mkdtempSync(join(tmpdir(), 'dsh-built-run-')) + try { + const result = await runBuiltBin(['run', 'answer', 'from', 'the', 'published', 'entry'], { + DSH_HOME: home, + DSH_TELEMETRY_DISABLED: '1', + DEEPSEEK_API_KEY: apiKey, + DEEPSEEK_BASE_URL: server.baseURL, + }) + expect(result.code, result.stderr).toBe(0) + expect(result.stdout).toBe('published dsh run reached the mock') + expect(result.stderr).toMatch(/^dsh: observing at http:\/\/127\.0\.0\.1:\d+$/u) + expect(server.requests.length).toBeGreaterThan(0) + expect(server.requests.every(request => request.path === '/chat/completions')).toBe(true) + expect(JSON.stringify(server.requests.map(request => request.body))).toContain('answer from the published entry') + } finally { + await server.close() + rmSync(home, { recursive: true, force: true }) + } + }, 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') diff --git a/apps/cli/tests/headless-shutdown.e2e.ts b/apps/cli/tests/headless-shutdown.e2e.ts index cfa87b03de..237554864c 100644 --- a/apps/cli/tests/headless-shutdown.e2e.ts +++ b/apps/cli/tests/headless-shutdown.e2e.ts @@ -66,7 +66,7 @@ async function runHeadlessPtySmoke(): Promise { try { const home = join(cwd, '.dsh') // Pre-initialize the headless profile with the never-dispose row in its - // user patch layer (the same file `dsh --profile headless` hot-reloads). + // user patch layer (the same file a long-lived profile boot hot-reloads). const profileDir = join(home, 'profiles', 'headless') await mkdir(profileDir, { recursive: true }) await writeFile(join(profileDir, 'package.json'), JSON.stringify({ @@ -83,7 +83,7 @@ async function runHeadlessPtySmoke(): Promise { ].join('\n')) const launch = resolveExampleLaunch({ srcBin: dshBinScript, - configArgs: ['--profile', 'headless', 'never complete'], + configArgs: ['run', 'never complete'], tsconfigPath, env: { DSH_HOME: home, diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 42c2f52565..eda24bb29f 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -509,7 +509,7 @@ export interface Config { } ``` -Source: [`packages/bundle/headless/src/index.ts:33`](../packages/bundle/headless/src/index.ts) +Source: [`packages/bundle/headless/src/index.ts:32`](../packages/bundle/headless/src/index.ts) ## `@deepseek-ai/dsh-hooks-claude` diff --git a/examples/headless-agent/tests/fixtures/dsh-run.cordis.yml b/examples/headless-agent/tests/fixtures/dsh-run.cordis.yml new file mode 100644 index 0000000000..e67630c029 --- /dev/null +++ b/examples/headless-agent/tests/fixtures/dsh-run.cordis.yml @@ -0,0 +1,8 @@ +- id: api-gateway + config: + provider: cli-mock + model: cli-mock + +- insert: + - id: cli-mock-llm + name: !!js process.env.DSH_RUN_MOCK_PLUGIN_URL diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts index f9cb46111a..9145c52f5a 100644 --- a/examples/headless-agent/tests/headless.snapshot.ts +++ b/examples/headless-agent/tests/headless.snapshot.ts @@ -2,7 +2,7 @@ import { readFile, readdir, writeFile } from 'node:fs/promises' import { createServer } from 'node:http' import type { IncomingMessage, ServerResponse } from 'node:http' import { delimiter, dirname, join } from 'node:path' -import { fileURLToPath } from 'node:url' +import { fileURLToPath, pathToFileURL } from 'node:url' import { normalizeSessionLog, normalizeStdout, @@ -14,6 +14,10 @@ import { type NormalizeContext, } from '@deepseek-ai/dsh-acp-snapshot' import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' +import { + decompressZstdFrame, + scanZstdFrames, +} from '@deepseek-ai/dsh-session-persistence-jsonl/src/zstd.ts' import { describe, expect, it } from 'vitest' const snapshotsDir = join(dirname(fileURLToPath(import.meta.url)), 'snapshots') @@ -44,9 +48,15 @@ const ralphConfigPath = fileURLToPath(new URL('../ralph.cordis.snapshot.yml', im const startupFailureConfigPath = fileURLToPath(new URL('./fixtures/startup-activation-error/cordis.yml', import.meta.url)) const startupFailureExpected = join(snapshotsDir, 'startup-activation-error', 'stderr.expected.txt') const binScript = fileURLToPath(new URL('../../../packages/examples/cli-demo/src/bin.ts', import.meta.url)) +const dshBinScript = fileURLToPath(new URL('../../../apps/cli/src/bin.ts', import.meta.url)) const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) const reasoningConfigPath = fileURLToPath(new URL('./fixtures/cli.cordis.yml', import.meta.url)) const deepseekDefaultsConfigPath = fileURLToPath(new URL('./fixtures/deepseek-defaults.cordis.yml', import.meta.url)) +const dshRunOverlayPath = fileURLToPath(new URL('./fixtures/dsh-run.cordis.yml', import.meta.url)) +const dshRunSessionExpected = join(snapshotsDir, 'dsh-run', 'session.expected.jsonl') +const cliMockLlmPluginUrl = pathToFileURL( + fileURLToPath(new URL('./fixtures/cli-mock-llm.ts', import.meta.url)), +).href const refreshing = process.env.DSH_SNAPSHOT === 'refresh' interface JsonObject { @@ -167,16 +177,61 @@ async function scenarioPrompt(dir: string, label: string): Promise { return prompt } -async function persistedLogs(cwd: string): Promise { - const root = join(cwd, '.sessions') - const files = (await readdir(root, { recursive: true })).filter(file => file.endsWith('.jsonl')) +async function readPersistedLog(file: string): Promise { + const content = await readFile(file) + if (!file.endsWith('.zstd')) return content.toString('utf8') + const scan = scanZstdFrames(content) + if (scan.tornStart !== undefined) throw new Error(`persisted snapshot log has a torn Zstandard frame: ${file}`) + const decoded: Buffer[] = [] + for (const frame of scan.frames) { + decoded.push(await decompressZstdFrame(content.subarray(frame.start, frame.end))) + } + return Buffer.concat(decoded).toString('utf8') +} + +async function persistedLogs(cwd: string, root: string = join(cwd, '.sessions')): Promise { + const files = (await readdir(root, { recursive: true })) + .filter(file => file.endsWith('.jsonl') || file.endsWith('.jsonl.zstd')) return Promise.all(files.map(async (file) => { - const content = await readFile(join(root, file), 'utf8') + const content = await readPersistedLog(join(root, file)) return { content, header: parseJsonl(content)[0] ?? {} } })) } describe('headless stream-json snapshots', () => { + it('runs one task through the product dsh run command', async () => { + const task = 'Prove the product dsh run path with one real tool round trip.' + const result = await runLoaderSmoke({ + label: 'product dsh run snapshot', + tempDirPrefix: 'headless-snapshot-dsh-run-', + binScript: dshBinScript, + configPath: dshRunOverlayPath, + binArgs: ['run', '--patch', dshRunOverlayPath, task], + tsconfigPath, + env: { + DSH_RUN_MOCK_PLUGIN_URL: cliMockLlmPluginUrl, + DSH_PERMISSION_MODE: 'danger-full-access', + DSH_TELEMETRY_DISABLED: '1', + NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '), + }, + inspect: async (cwd) => { + const logs = await persistedLogs(cwd, join(cwd, '.dsh', 'sessions')) + expect(logs).toHaveLength(1) + const actual = logs[0] + if (actual === undefined) throw new Error('dsh run did not persist its session') + const context = contextFromLogs([actual.content]) + const session = scrubRequestHeaders(normalizeSessionLog(actual.content, context)) + if (refreshing) await writeFile(dshRunSessionExpected, session) + expect(session).toBe(await readFile(dshRunSessionExpected, 'utf8')) + expect(session).toContain(task) + expect(session).toContain('CLI tool round trip complete: CLI_TOOL_ROUND_TRIP') + }, + }) + + expect(result.stdout).toBe('CLI tool round trip complete: CLI_TOOL_ROUND_TRIP\n') + expect(result.stderr).toMatch(/^dsh: observing at http:\/\/127\.0\.0\.1:\d+\n$/u) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('prints the original Loader activation error through the assembled one-shot app', async () => { const result = await runLoaderSmoke({ label: 'headless startup activation error snapshot', diff --git a/examples/headless-agent/tests/snapshots/dsh-run/session.expected.jsonl b/examples/headless-agent/tests/snapshots/dsh-run/session.expected.jsonl new file mode 100644 index 0000000000..1312eb6511 --- /dev/null +++ b/examples/headless-agent/tests/snapshots/dsh-run/session.expected.jsonl @@ -0,0 +1,33 @@ +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"permission/preset","seq":0,"time":0,"data":{"preset":"danger-full-access"}} +{"type":"sandbox/mode","seq":1,"time":0,"data":{"mode":"danger-full-access"}} +{"type":"approval/policy","seq":2,"time":0,"data":{"policy":"never"}} +{"type":"agent/inbox/spliced","seq":3,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Prove the product dsh run path with one real tool round trip."}],"source":{"kind":"user","rpcId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"}]}} +{"type":"turn/start","seq":4,"time":0,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":5,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":7,"time":0,"data":{"content":[{"type":"text","text":"Prove the product dsh run path with one real tool round trip."}],"source":{"kind":"user","rpcId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"session/title","seq":9,"time":0,"data":{"title":"Prove the product dsh run","messageSeqs":[7],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"cli-mock","model":"cli-mock","reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":11,"time":0,"data":{"provider":"cli-mock","model":"cli-mock"}} +{"type":"session/title-llm-request","seq":12,"time":0,"data":{"titleProvider":"session-title-first-message-llm","messageSeqs":[7],"route":{"provider":"cli-mock","model":"cli-mock"},"system":"Create a concise title for an AI coding-assistant session from the supplied human messages.\nReturn only the title on one line, **in plain text of natural language**, with no quotes, prefix, explanation, Markdown, XML, or terminal control codes. No code is allowed.\nUse the language of the messages.\nAim for about 5 words in non-CJK languages or 10 CJK characters.","messages":[{"content":[{"type":"text","text":"Generate the session title from this JSON array of human messages:\n[{\"seq\":7,\"text\":\"Prove the product dsh run path with one real tool round trip.\"}]"}],"source":{"kind":"plugin","plugin":"dsh-session-title-llm"},"role":"user","id":"{{sessionId}}"}],"maxTokens":64}} +{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"cli-smoke-call","name":"bash","argumentsDelta":"{\"command\":\"printf CLI_TOOL_ROUND_TRIP\",\"description\":\"Prove the CLI tool round trip.\"}"}}} +{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cli-smoke-call","name":"bash","arguments":"{\"command\":\"printf CLI_TOOL_ROUND_TRIP\",\"description\":\"Prove the CLI tool round trip.\"}"}}}} +{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":11,"outputTokens":3,"cacheReadTokens":2}}}} +{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":18,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cli-smoke-call","name":"bash","arguments":"{\"command\":\"printf CLI_TOOL_ROUND_TRIP\",\"description\":\"Prove the CLI tool round trip.\"}"}],"source":{"kind":"model","provider":"cli-mock","model":"cli-mock"},"id":"{{sessionId}}"},"usage":{"inputTokens":11,"outputTokens":3,"cacheReadTokens":2}},"sourceEventSeqs":[13,14,15,16,17],"surfaceOp":"append"} +{"type":"tool/call","seq":19,"time":0,"data":{"turn":1,"step":1,"callId":"cli-smoke-call","name":"bash","arguments":"{\"command\":\"printf CLI_TOOL_ROUND_TRIP\",\"description\":\"Prove the CLI tool round trip.\"}"}} +{"type":"tool/result","seq":20,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"cli-smoke-call"},"content":[{"type":"tool-result","toolCallId":"cli-smoke-call","content":[{"type":"text","text":"CLI_TOOL_ROUND_TRIP"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[19],"surfaceOp":"append"} +{"type":"step/end","seq":21,"time":0,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":22,"time":0,"data":{"turn":1,"step":2}} +{"type":"request/header","seq":23,"time":0,"data":{"header":{"config":{"provider":"cli-mock","model":"cli-mock","reasoningEffort":"off"},"system":"{{system}}","tools":"{{tools}}"},"reason":"change"}} +{"type":"assistant/chunk","seq":24,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"CLI tool round trip complete: CLI_TOOL_ROUND_TRIP"}}} +{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CLI tool round trip complete: CLI_TOOL_ROUND_TRIP"}}}} +{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":7,"outputTokens":5,"reasoningTokens":1}}}} +{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":29,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"CLI tool round trip complete: CLI_TOOL_ROUND_TRIP"}],"source":{"kind":"model","provider":"cli-mock","model":"cli-mock"},"id":"{{sessionId}}"},"usage":{"inputTokens":7,"outputTokens":5,"reasoningTokens":1}},"sourceEventSeqs":[24,25,26,27,28],"surfaceOp":"append"} +{"type":"step/end","seq":30,"time":0,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":31,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/packages/bundle/headless/README.i18n.yaml b/packages/bundle/headless/README.i18n.yaml index 08e4a5a5b5..f1a9d53be8 100644 --- a/packages/bundle/headless/README.i18n.yaml +++ b/packages/bundle/headless/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/bundle/headless/README.md -README.md: d08fb08e2aca3c4e5ccd733b37fc415d492974ca -README.zh.md: 99a64ef04c4fd8fb0c6a979d3f09f1bd98b434a0 +README.md: 661b377817482d22f58f22b573075722646729a2 +README.zh.md: a6b91a8e60fdcc06ba23e07dcb2f4208ea1020f7 diff --git a/packages/bundle/headless/README.md b/packages/bundle/headless/README.md index d08fb08e2a..661b377817 100644 --- a/packages/bundle/headless/README.md +++ b/packages/bundle/headless/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The dsh one-shot bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md) + [`dsh-web-app`](../web-app/README.md): it moves the webserver to an OS-assigned port (parallel runs never collide), silences the URL line, and inserts this package's `headless-runner` plugin (config `{task}`). The runner drives one task turn through the in-process API carrier (`InProcessApiClient` over `toFetchHandler(ctx.apiProxy)`, so the full wire chain — serialization, zod, SSE framing — really runs), aggregates the turn's final assistant text, writes it to stdout, and requests exit (completed → 0, else 1) through the launcher-provided `ctx.headlessIo` seam. The Web composition stays mounted, so the running session is observable in a browser at the stderr-announced URL. The launcher patches the task text in (`dsh --profile headless "task"`), and fails loud when a task is given to a profile without this row. +The dsh one-shot bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md) + [`dsh-web-app`](../web-app/README.md): it moves the webserver to an OS-assigned port (parallel runs never collide), silences the URL line, and inserts this package's `headless-runner` plugin (config `{task}`). The runner drives one task turn through the in-process API carrier (`InProcessApiClient` over `toFetchHandler(ctx.apiProxy)`, so the full wire chain — serialization, zod, SSE framing — really runs), waits at idle until that mux has consumed the session's final event sequence, aggregates the turn's final assistant text, writes it to stdout, and requests exit (completed → 0, else 1) through the launcher-provided `ctx.headlessIo` seam. The Web composition stays mounted, so the running session is observable in a browser at the stderr-announced URL. The launcher patches the task text in (`dsh run "task"`), and fails loud when the selected profile lacks this row. ## Model Experience diff --git a/packages/bundle/headless/README.zh.md b/packages/bundle/headless/README.zh.md index 99a64ef04c..a6b91a8e60 100644 --- a/packages/bundle/headless/README.zh.md +++ b/packages/bundle/headless/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -dsh 一次性任务组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) + [`dsh-web-app`](../web-app/README.md) 之上:把 webserver 移到 OS 分配的端口(并行运行绝不冲突),关闭 URL 行输出,并插入本包的 `headless-runner` 插件(配置为 `{task}`)。runner 通过进程内 API 载体(架在 `toFetchHandler(ctx.apiProxy)` 之上的 `InProcessApiClient`,因此序列化、zod、SSE(Server-Sent Events)帧封装这整条 wire 链路都会真实运行)驱动一个任务轮次,聚合该轮次最终的 assistant 文本,写到 stdout,再经启动器提供的 `ctx.headlessIo` seam 请求退出(完成 → 0,否则 1)。Web 组合保持挂载,因此运行中的会话可在浏览器中通过 stderr 公告的 URL 观察。启动器把任务文本 patch 进来(`dsh --profile headless "task"`);如果向没有这一行的 profile 传入任务,则大声失败。 +dsh 一次性任务组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) + [`dsh-web-app`](../web-app/README.md) 之上:把 webserver 移到 OS 分配的端口(并行运行绝不冲突),关闭 URL 行输出,并插入本包的 `headless-runner` 插件(配置为 `{task}`)。runner 通过进程内 API 载体(架在 `toFetchHandler(ctx.apiProxy)` 之上的 `InProcessApiClient`,因此序列化、zod、SSE(Server-Sent Events)帧封装这整条 wire 链路都会真实运行)驱动一个任务轮次,在 idle 时等待该 mux 消费完会话的最终事件序号,再聚合该轮次最终的 assistant 文本,写到 stdout,并经启动器提供的 `ctx.headlessIo` seam 请求退出(完成 → 0,否则 1)。Web 组合保持挂载,因此运行中的会话可在浏览器中通过 stderr 公告的 URL 观察。启动器把任务文本 patch 进来(`dsh run "task"`);若所选 profile 缺少该行,则显式报错。 ## 模型体验 diff --git a/packages/bundle/headless/src/index.ts b/packages/bundle/headless/src/index.ts index 572f11b487..8db505c3ac 100644 --- a/packages/bundle/headless/src/index.ts +++ b/packages/bundle/headless/src/index.ts @@ -6,8 +6,7 @@ * (InProcessApiClient over toFetchHandler(ctx.apiProxy), so the full wire * chain — serialization, zod, SSE framing — really runs), prints the final * assistant text at agent quiescence, and exits (completed → 0, else 1). The - * task text arrives as launcher-patched config - * (`dsh --profile headless "task"`). + * task text arrives as launcher-patched config (`dsh run "task"`). * @module @deepseek-ai/dsh-headless */ @@ -86,26 +85,31 @@ async function unwrap(response: RpcResponse, io: HeadlessIo): Promise { * `agent/status` subscription; the stream itself carries no status frame. * @param frames - the mux stream opened before the prompt. * @param sessionId - the headless session. - * @param idle - resolves when the agent reaches quiescence. + * @param idle - resolves to the final session-event sequence when the agent reaches quiescence. * @param io - process-facing effects for stream diagnostics. * @returns the aggregated outcome. */ async function consumeUntilIdle( frames: AsyncIterable>, sessionId: SessionId, - idle: Promise, + idle: Promise, io: HeadlessIo, ): Promise { let started = false let text = '' let reason: string = 'error' - void (async () => { + let observedSeq = -1 + let resolveProgress: (() => void) | undefined + const streamDone = (async () => { try { for await (const frame of frames) { const payload = frame.payload if (payload.type === 'stream/error') return if (payload.type !== 'session/event' || payload.sessionId !== sessionId) continue const event = payload.event + observedSeq = event.seq + resolveProgress?.() + resolveProgress = undefined if (event.type === 'turn/start') { started = true continue @@ -121,7 +125,12 @@ async function consumeUntilIdle( io.stderr.write(`dsh: event stream failed: ${String(error)}\n`) } })() - await idle + const streamEnded = streamDone.then(() => 'ended' as const) + const idleSeq = await idle + while (observedSeq < idleSeq) { + const progress = new Promise<'progress'>((resolve) => { resolveProgress = () => { resolve('progress') } }) + if (await Promise.race([progress, streamEnded]) === 'ended') break + } return { text, reason } } @@ -154,9 +163,9 @@ export function apply(ctx: Context, config: Config): void { // port of this runner must replace it with a wire-visible idle signal. const abort = new AbortController() const frames = api.events.mux({}, abort.signal) - const idle = new Promise((resolve) => { + const idle = new Promise((resolve) => { ctx.on('agent/status', ({ agent, status }) => { - if (agent.id === created.sessionId && status === 'idle') resolve() + if (agent.id === created.sessionId && status === 'idle') resolve(agent.session.seq - 1) }) }) const done = consumeUntilIdle(frames, created.sessionId, idle, io) diff --git a/packages/bundle/headless/tests/headless.spec.ts b/packages/bundle/headless/tests/headless.spec.ts index 6ff96619ff..9408ee62bf 100644 --- a/packages/bundle/headless/tests/headless.spec.ts +++ b/packages/bundle/headless/tests/headless.spec.ts @@ -21,26 +21,45 @@ function stamped(event: ScriptedEvent): ScriptedEvent { interface RpcShapedRequest { rpcId: string } +interface ScriptedApiOptions { + promptFails?: boolean + framesAfterPrompt?: boolean + onPrompt?: () => void +} + /** Build a fake apiProxy (echoing rpcIds like the real gateway) whose mux stream replays `events` for the created session. */ -function scriptedApi(events: ScriptedEvent[], options: { promptFails?: boolean } = {}): unknown { +function scriptedApi(events: ScriptedEvent[], options: ScriptedApiOptions = {}): unknown { + let releaseFrames = (): void => {} + const framesReady = options.framesAfterPrompt === true + ? new Promise((resolve) => { releaseFrames = resolve }) + : Promise.resolve() + const prepared = events.map((event) => { + if (event.type === 'stream/error') return { streamError: true } as const + const { sessionId = 'S1', ...rest } = event + return { streamError: false, sessionId, event: stamped(rest) } as const + }) return { sessions: { create: (request: RpcShapedRequest) => Promise.resolve({ rpcId: request.rpcId, result: { ok: true, value: { sessionId: 'S1' } } }), - prompt: (request: RpcShapedRequest) => Promise.resolve(options.promptFails === true - // A code from the closed wire union: the carrier schema rejects invented codes. - ? { rpcId: request.rpcId, result: { ok: false, error: { code: 'agent-busy', message: 'agent is busy', details: { reason: 'test' } } } } - : { rpcId: request.rpcId, result: { ok: true, value: { accepted: true } } }), + prompt: (request: RpcShapedRequest) => { + releaseFrames() + options.onPrompt?.() + return Promise.resolve(options.promptFails === true + // A code from the closed wire union: the carrier schema rejects invented codes. + ? { rpcId: request.rpcId, result: { ok: false, error: { code: 'agent-busy', message: 'agent is busy', details: { reason: 'test' } } } } + : { rpcId: request.rpcId, result: { ok: true, value: { accepted: true } } }) + }, }, events: { mux: async function* () { - for (const event of events) { - if (event.type === 'stream/error') { + await framesReady + for (const item of prepared) { + if (item.streamError) { yield { rpcId: 'e', payload: { type: 'stream/error', error: { code: 'cancelled', message: 'stream broke', details: {} } } } continue } - const { sessionId = 'S1', ...rest } = event - yield { rpcId: 'e', payload: { type: 'session/event', sessionId, event: stamped(rest) } } + yield { rpcId: 'e', payload: { type: 'session/event', sessionId: item.sessionId, event: item.event } } } }, }, @@ -51,7 +70,10 @@ function scriptedApi(events: ScriptedEvent[], options: { promptFails?: boolean } * Mount the runner against a scripted API, emit the idle transition after the * scripted frames drain, and wait for its exit request. */ -async function run(events: ScriptedEvent[], options: { promptFails?: boolean } = {}): Promise<{ code: number; out: string; err: string }> { +async function run( + events: ScriptedEvent[], + options: { promptFails?: boolean; framesAfterPrompt?: boolean; idleInPrompt?: boolean } = {}, +): Promise<{ code: number; out: string; err: string }> { const ctx = new Context() let out = '' let err = '' @@ -63,16 +85,25 @@ async function run(events: ScriptedEvent[], options: { promptFails?: boolean } = } ctx.provide('headlessIo', io) }) - ctx.provide('apiProxy', scriptedApi(events, options) as never) + const emitIdle = (): void => { + ctx.emit('agent/status', { agent: { id: 'S1', session: { seq: nextSeq + 1 } } as Agent, status: 'idle' }) + } + ctx.provide('apiProxy', scriptedApi(events, { + ...options.promptFails === undefined ? {} : { promptFails: options.promptFails }, + ...options.framesAfterPrompt === undefined ? {} : { framesAfterPrompt: options.framesAfterPrompt }, + ...options.idleInPrompt === true ? { onPrompt: emitIdle } : {}, + }) as never) ctx.provide('httpServer', { port: 12345 } as never) apply(ctx, { task: 'do the thing' }) // Quiescence is out of band: give the scripted stream a beat to drain, then // flip the agent idle exactly as the loop would. Foreign agents and // non-idle transitions must not settle the run. - await new Promise(resolve => setTimeout(resolve, 10)) - ctx.emit('agent/status', { agent: { id: 'OTHER' } as Agent, status: 'idle' }) - ctx.emit('agent/status', { agent: { id: 'S1' } as Agent, status: 'running' }) - ctx.emit('agent/status', { agent: { id: 'S1' } as Agent, status: 'idle' }) + if (options.idleInPrompt !== true) { + await new Promise(resolve => setTimeout(resolve, 10)) + ctx.emit('agent/status', { agent: { id: 'OTHER' } as Agent, status: 'idle' }) + ctx.emit('agent/status', { agent: { id: 'S1' } as Agent, status: 'running' }) + emitIdle() + } const code = await exited await ctx.fiber.dispose() return { code, out, err } @@ -106,6 +137,15 @@ describe('headless runner', () => { expect(err).toContain('observing at http://127.0.0.1:12345') }) + it('consumes through the idle sequence when queued frames arrive after the status transition', async () => { + const { code, out } = await run( + [messageTurn, text(1, 'race-free answer'), end(1, 'completed')], + { framesAfterPrompt: true, idleInPrompt: true }, + ) + expect(code).toBe(0) + expect(out).toBe('race-free answer\n') + }) + it('exits 1 when the final turn ends for any other reason', async () => { const { code } = await run([messageTurn, end(1, 'aborted')]) expect(code).toBe(1) @@ -168,7 +208,7 @@ describe('headless runner', () => { ctx.provide('httpServer', { port: 1 } as never) apply(ctx, { task: 't' }) await new Promise(resolve => setTimeout(resolve, 10)) - ctx.emit('agent/status', { agent: { id: 'S1' } as Agent, status: 'idle' }) + ctx.emit('agent/status', { agent: { id: 'S1', session: { seq: nextSeq + 1 } } as Agent, status: 'idle' }) expect(await exited).toBe(1) expect(err).toContain('event stream failed') await ctx.fiber.dispose() diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 961b48dd0b..627f624235 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/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/host/apiproxy/README.md -README.md: 5506cbef7b778a870e1e28c3f9fdf1713f89d65f -README.zh.md: de31f653944097e9b47a966f56c418dc9fa9b1b9 +README.md: a3c9f214690144ec0f39a8690e4fd346f5e315e2 +README.zh.md: aeaf1b29e5a71674c9feedb30b67f9ce11c47340 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 5506cbef7b..a3c9f21469 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -52,7 +52,7 @@ The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-pag ## Carrier layer (`/client` + root) -`AbstractApiClient` holds every protocol invariant — rpcId minting, envelope wrap/unwrap, zod parsing, SSE frame decoding, unary timeout, microtask-batched envelope observation (`subscribeEnvelopes`) — while platform subclasses supply only the `doFetch` transport aspect. `InProcessApiClient` over `toFetchHandler(api)` is the isomorphic point: the full wire serialization/validation path with no network, used by `dsh -p` headless. +`AbstractApiClient` holds every protocol invariant — rpcId minting, envelope wrap/unwrap, zod parsing, SSE frame decoding, unary timeout, microtask-batched envelope observation (`subscribeEnvelopes`) — while platform subclasses supply only the `doFetch` transport aspect. `InProcessApiClient` over `toFetchHandler(api)` is the isomorphic point: the full wire serialization/validation path with no network, used by `dsh run` headless. ## Model Experience diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index de31f65394..aeaf1b29e5 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -52,7 +52,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr ## 载体层(`/client` + 根路径) -`AbstractApiClient` 持有全部协议不变量:签发 rpcId、包装/解包信封、Zod 解析、SSE 帧解码、一元请求超时,以及按微任务批处理的信封观测(`subscribeEnvelopes`);平台子类只提供 `doFetch` 传输环节。`InProcessApiClient` 以 `toFetchHandler(api)` 为基础,是同构接点:它运行完整的协议序列化与校验路径而不经过网络,供 `dsh -p` headless 模式使用。 +`AbstractApiClient` 持有全部协议不变量:签发 rpcId、包装/解包信封、Zod 解析、SSE 帧解码、一元请求超时,以及按微任务批处理的信封观测(`subscribeEnvelopes`);平台子类只提供 `doFetch` 传输环节。`InProcessApiClient` 以 `toFetchHandler(api)` 为基础,是同构接点:它运行完整的协议序列化与校验路径而不经过网络,供 `dsh run` headless 模式使用。 ## 模型体验 From 21c380be52886cd2250878f49e4ff92cfa78f5f8 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 02:08:31 +0800 Subject: [PATCH 077/109] docs(agent-notes): archive superseded dsh entrypoint decision --- .agents/notes/archived/manifest.json | 3 +++ .../2026-08-03-explicit-config-dsh-entrypoint.i18n.yaml | 4 ++-- .../2026-08-03-explicit-config-dsh-entrypoint.md | 1 + .../2026-08-03-explicit-config-dsh-entrypoint.zh.md | 1 + .../simplification/2026-08-04-remove-tui-package.i18n.yaml | 4 ++-- .../simplification/2026-08-04-remove-tui-package.md | 2 +- .../simplification/2026-08-04-remove-tui-package.zh.md | 2 +- 7 files changed, 11 insertions(+), 6 deletions(-) rename .agents/notes/{implemented => archived}/simplification/2026-08-03-explicit-config-dsh-entrypoint.i18n.yaml (68%) rename .agents/notes/{implemented => archived}/simplification/2026-08-03-explicit-config-dsh-entrypoint.md (99%) rename .agents/notes/{implemented => archived}/simplification/2026-08-03-explicit-config-dsh-entrypoint.zh.md (99%) diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index c46bb59b44..1adde54c7b 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -376,6 +376,9 @@ "simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.i18n.yaml": "sha256:531c446f0e95054f8ced17be9a180f8b0a823f7e9d5ce466c94c2f9cff90a111", "simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md": "sha256:a35a6372aabdf7cbc211f1bd5820d85d3467c9ed50f84e05caa3339382379ce7", "simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md": "sha256:a6ed9530289a783c3d7a1ddb038fba6b7daf7feb773298a57e811791e354d438", + "simplification/2026-08-03-explicit-config-dsh-entrypoint.i18n.yaml": "sha256:5466161f3fb8f2e8117fe8ff242675cc9fe9ef264d1e29b9bc586891c73c051a", + "simplification/2026-08-03-explicit-config-dsh-entrypoint.md": "sha256:f23accae7d05c2e75cb73ec69b492307f1ce7526ecfa9f6b12a621e02fd1a0c3", + "simplification/2026-08-03-explicit-config-dsh-entrypoint.zh.md": "sha256:a32d2c6ecf748a16a2c35b59cd2da2fda75769e3ab24be6a2e026d8655466db4", "testing/2026-06-20-remove-redundant-snapshot-log-expected-output.i18n.yaml": "sha256:4177012c0821a8c22499852ecdf096af56d7263cb91c5d9d1bcd552cc26a3e00", "testing/2026-06-20-remove-redundant-snapshot-log-expected-output.md": "sha256:45234e7cc04b6010c6141f8d5924c04547300098f96262d423c50108e7c7011a", "testing/2026-06-20-remove-redundant-snapshot-log-expected-output.zh.md": "sha256:15e5a4ad3dee0bb711480cabe45cd97ec37bbdba19c2c2b47d1e9c203b07a48b", diff --git a/.agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.i18n.yaml b/.agents/notes/archived/simplification/2026-08-03-explicit-config-dsh-entrypoint.i18n.yaml similarity index 68% rename from .agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.i18n.yaml rename to .agents/notes/archived/simplification/2026-08-03-explicit-config-dsh-entrypoint.i18n.yaml index ee43f9465c..b699495f93 100644 --- a/.agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.i18n.yaml +++ b/.agents/notes/archived/simplification/2026-08-03-explicit-config-dsh-entrypoint.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-03-explicit-config-dsh-entrypoint.md -2026-08-03-explicit-config-dsh-entrypoint.md: e0d1e954d9cef472ea59345a3d2ef5a67bd03ae8 -2026-08-03-explicit-config-dsh-entrypoint.zh.md: 614c2d8600731d85c83d6559bc577350da25e872 +2026-08-03-explicit-config-dsh-entrypoint.md: 4474e786b3a99ff0ee81ac54fcb6eb5aaff5ee04 +2026-08-03-explicit-config-dsh-entrypoint.zh.md: 11b7b27c7560aaa43a9fdcfb12a9a17f9433e619 diff --git a/.agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.md b/.agents/notes/archived/simplification/2026-08-03-explicit-config-dsh-entrypoint.md similarity index 99% rename from .agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.md rename to .agents/notes/archived/simplification/2026-08-03-explicit-config-dsh-entrypoint.md index e0d1e954d9..4474e786b3 100644 --- a/.agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.md +++ b/.agents/notes/archived/simplification/2026-08-03-explicit-config-dsh-entrypoint.md @@ -1,6 +1,7 @@ # Agent Note: Explicit-config dsh entrypoint Status: implemented +Archived: 2026-08-08 English | [中文](2026-08-03-explicit-config-dsh-entrypoint.zh.md) diff --git a/.agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.zh.md b/.agents/notes/archived/simplification/2026-08-03-explicit-config-dsh-entrypoint.zh.md similarity index 99% rename from .agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.zh.md rename to .agents/notes/archived/simplification/2026-08-03-explicit-config-dsh-entrypoint.zh.md index 614c2d8600..11b7b27c75 100644 --- a/.agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.zh.md +++ b/.agents/notes/archived/simplification/2026-08-03-explicit-config-dsh-entrypoint.zh.md @@ -1,6 +1,7 @@ # Agent Note: 显式配置的 dsh 入口 Status: implemented +Archived: 2026-08-08 [English](2026-08-03-explicit-config-dsh-entrypoint.md) | 中文 diff --git a/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.i18n.yaml index cd1133483c..e71be9bcf0 100644 --- a/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-08-04-remove-tui-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 .agents/notes/implemented/simplification/2026-08-04-remove-tui-package.md -2026-08-04-remove-tui-package.md: 7f7a0dd86ddd36e940ed8b7d6154185d9740341c -2026-08-04-remove-tui-package.zh.md: 36cb4b6a5e4eddd152eccee92c913bcca5b7fbae +2026-08-04-remove-tui-package.md: 1057243c70f6f2775a5d0c5f5eddcb72cbad699e +2026-08-04-remove-tui-package.zh.md: 0e03d6913aafaa3ce01c0f5732935d2c304c8e0e diff --git a/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.md b/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.md index 7f7a0dd86d..1057243c70 100644 --- a/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.md +++ b/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.md @@ -16,7 +16,7 @@ The `packages/ui/tui` package is deleted without a compatibility package or alia The SDK run-interface union now contains only `acp` and `embed`. `create-sdk` defaults to ACP, generated templates contain no terminal startup, resume, session-environment, or model-argument branch, and the builtin `ask-user` feature is removed because neither remaining generated interface supplies a `UserInteractionProvider`. Host applications may still mount the provider-neutral `dsh-user-interaction`, `dsh-commands`, and presentation seams directly. -This decision supersedes the reusable-package retention in [the explicit-config `dsh` entrypoint decision](2026-08-03-explicit-config-dsh-entrypoint.md) and the current applicability of the archived TUI implementation notes. Their historical records remain frozen, but they are not authority for the supported package or application inventory. +This decision supersedes the reusable-package retention in [the explicit-config `dsh` entrypoint decision](../../archived/simplification/2026-08-03-explicit-config-dsh-entrypoint.md) and the current applicability of the archived TUI implementation notes. Their historical records remain frozen, but they are not authority for the supported package or application inventory. This note consolidates the deleted package-only records that could not remain current after removal. The terminal UI had kept session identity visible during long conversations, removed duplicate model labels, attached elapsed timing and phase status to messages, showed workspace and branch context beside the prompt, and conservatively parsed complete XML wrappers for human-readable fallback output. Those choices improved one terminal frontend but do not justify retaining it without a deployment. A future XML fallback must still use a real parser rather than regular expressions. diff --git a/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.zh.md b/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.zh.md index 36cb4b6a5e..0e03d6913a 100644 --- a/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.zh.md +++ b/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.zh.md @@ -16,7 +16,7 @@ Status: implemented SDK 的运行接口联合类型现在只包含 `acp` 与 `embed`。`create-sdk` 默认使用 ACP,生成的模板不再包含终端启动、恢复、会话环境或模型参数分支;内置的 `ask-user` 功能也被移除,因为剩余两个生成接口都不提供 `UserInteractionProvider`。宿主应用仍可直接挂载提供方无关的 `dsh-user-interaction`、`dsh-commands` 和呈现 seam。 -本决策取代[显式配置 `dsh` 入口决策](2026-08-03-explicit-config-dsh-entrypoint.md)中保留可复用包的决定,也使已归档 TUI 实现记录不再适用于当前状态。这些历史记录继续保持冻结,但不再作为受支持包或应用清单的依据。 +本决策取代[显式配置 `dsh` 入口决策](../../archived/simplification/2026-08-03-explicit-config-dsh-entrypoint.md)中保留可复用包的决定,也使已归档 TUI 实现记录不再适用于当前状态。这些历史记录继续保持冻结,但不再作为受支持包或应用清单的依据。 本记录汇总了删除后无法继续保持当前状态的仅限包记录。终端 UI 曾在长对话期间保持会话身份可见、移除重复模型标签、为消息附加耗时与阶段状态、在提示词旁显示 workspace 与分支上下文,并保守地解析完整 XML 包装层,以生成人类可读的回退输出。这些选择改善了一个终端前端,但没有部署时不足以证明应保留它。未来的 XML 回退仍必须使用真实解析器而非正则表达式。 From 33a7b1284e490e6445f5212d016070613a85be07 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 02:20:11 +0800 Subject: [PATCH 078/109] test(snapshot): keep dsh run plugin metadata static --- .../tests/fixtures/dsh-run.cordis.yml | 2 +- .../headless-agent/tests/headless.snapshot.ts | 17 +++++++++++------ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/examples/headless-agent/tests/fixtures/dsh-run.cordis.yml b/examples/headless-agent/tests/fixtures/dsh-run.cordis.yml index e67630c029..7d411f7a0f 100644 --- a/examples/headless-agent/tests/fixtures/dsh-run.cordis.yml +++ b/examples/headless-agent/tests/fixtures/dsh-run.cordis.yml @@ -5,4 +5,4 @@ - insert: - id: cli-mock-llm - name: !!js process.env.DSH_RUN_MOCK_PLUGIN_URL + name: './snapshot-fixtures/cli-mock-llm.ts' diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts index 9145c52f5a..e8561b0196 100644 --- a/examples/headless-agent/tests/headless.snapshot.ts +++ b/examples/headless-agent/tests/headless.snapshot.ts @@ -1,8 +1,8 @@ -import { readFile, readdir, writeFile } from 'node:fs/promises' +import { copyFile, mkdir, readFile, readdir, writeFile } from 'node:fs/promises' import { createServer } from 'node:http' import type { IncomingMessage, ServerResponse } from 'node:http' import { delimiter, dirname, join } from 'node:path' -import { fileURLToPath, pathToFileURL } from 'node:url' +import { fileURLToPath } from 'node:url' import { normalizeSessionLog, normalizeStdout, @@ -54,9 +54,7 @@ const reasoningConfigPath = fileURLToPath(new URL('./fixtures/cli.cordis.yml', i const deepseekDefaultsConfigPath = fileURLToPath(new URL('./fixtures/deepseek-defaults.cordis.yml', import.meta.url)) const dshRunOverlayPath = fileURLToPath(new URL('./fixtures/dsh-run.cordis.yml', import.meta.url)) const dshRunSessionExpected = join(snapshotsDir, 'dsh-run', 'session.expected.jsonl') -const cliMockLlmPluginUrl = pathToFileURL( - fileURLToPath(new URL('./fixtures/cli-mock-llm.ts', import.meta.url)), -).href +const cliMockLlmPluginPath = fileURLToPath(new URL('./fixtures/cli-mock-llm.ts', import.meta.url)) const refreshing = process.env.DSH_SNAPSHOT === 'refresh' interface JsonObject { @@ -209,11 +207,18 @@ describe('headless stream-json snapshots', () => { binArgs: ['run', '--patch', dshRunOverlayPath, task], tsconfigPath, env: { - DSH_RUN_MOCK_PLUGIN_URL: cliMockLlmPluginUrl, DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1', NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '), }, + prepare: async (cwd) => { + const fixtureDir = join(cwd, '.dsh', 'profiles', 'headless', 'snapshot-fixtures') + await mkdir(fixtureDir, { recursive: true }) + await Promise.all([ + copyFile(cliMockLlmPluginPath, join(fixtureDir, 'cli-mock-llm.ts')), + writeFile(join(fixtureDir, 'package.json'), '{"type":"module"}\n'), + ]) + }, inspect: async (cwd) => { const logs = await persistedLogs(cwd, join(cwd, '.dsh', 'sessions')) expect(logs).toHaveLength(1) From e8ee305b7b68063a9246b8eaf3554d8e0a0052fe Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 02:30:52 +0800 Subject: [PATCH 079/109] test(snapshot): refresh dsh run translation prompt fixture --- .../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 0748e762dd..f0a9390b78 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -8,11 +8,11 @@ }, { "role": "user", - "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.\n\nIt uses an architecture where **everything is a plugin**.\n\n## Internal testing notice\n\nDeepSeek Harness is under internal testing. Features and interfaces may change.\n\nThe internal build uploads all Session Logs by default to help diagnose reported problems. Set `DSH_TELEMETRY_DISABLED=1` to disable telemetry. Send feedback through the internal WeChat group.\n\n## Install\n\nClone the repository, then run the installer:\n\n```sh\ngit clone \ncd deepseek-harness\nscripts/install.sh\n```\n\nThe installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, prompts for a DeepSeek API key, builds the required repository artifacts, and launches the Web UI.\n\nThe default active checkout is `~/.dsh/source/current`, and the launcher is linked into `~/.local/bin`. Re-run the installer to update. [`scripts/install.sh`](scripts/install.sh) owns alternate locations, update mechanics, and recovery options.\n\n## Use DeepSeek Harness\n\n### Web UI\n\nFor the recommended local interface, choose Web UI when the installer finishes. To start it later, or after updating the active checkout, build the repository and run:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\nThe path above is the installer's default. If you set `DSH_SOURCE` or `DSH_CURRENT`, or reused an existing checkout, replace `~/.dsh/source/current` with that checkout path; see [`scripts/install.sh`](scripts/install.sh) for details. The Web UI is served at `http://127.0.0.1:3080` by default.\n\n### Profiles\n\n`dsh` boots profiles — ordered stacks of plugin-bundle patch layers under your own overrides in `$DSH_HOME/profiles/`:\n\n```sh\ndsh --profile web # the browser UI (same as: dsh web)\ndsh plugin --profile tui add # install a plugin into a custom profile\ndsh --profile tui # boot it\n```\n\nThe [CLI contract](apps/cli/README.md#profiles) describes profile layout, layer semantics, and config dump commands.\n\n### Headless\n\nRun one task, print the final answer, and exit:\n\n```sh\ndsh --profile headless \"summarize this workspace\"\n```\n\n### Automation and SDKs\n\nFrom a source checkout with `DEEPSEEK_API_KEY` in the environment or its root `.env`, start the ACP automation server:\n\n```sh\npnpm run demo:acp\n```\n\nThe [Python SDK](python/README.md) drives a bundled JSON-RPC runtime. The [examples](examples/README.md) cover the runnable headless, ACP, JSON-RPC, Code Mode, and self-referential compositions.\n\n## Why DeepSeek Harness\n\nBuilt-in capabilities cover file reading, editing, and search; shell and persistent PTY execution; reusable skills; task tracking, goals, plans, todos, and background tasks; subagents and workflows; sandboxing and approvals; settings and credentials; persistent, resumable, forkable, and queryable sessions; LSP and web access; context compaction; and telemetry. Each composition selects the subset appropriate to its surface. The Web UI includes Plan Mode.\n\n- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design.\n- **Runs are reconstructable.** Anything visible to the model is logged in the authoritative session stream; persistence, resume/fork/query, replay, telemetry, and UIs derive from the same events. See the [session-log architecture](docs/architecture.md#session-log).\n- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode).\n- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/cordis/tool-cordis/README.md).\n\n## Community\n\nFollow DeepSeek Harness on Twitter for project updates.\n\n## Development\n\nStart with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\nDeepSeek Harness is currently in internal testing.\n\n## License\n\n[BSD 3-Clause](LICENSE)\n\nThird-party dependencies and their licenses are disclosed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).\n" + "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.\n\nIt uses an architecture where **everything is a plugin**.\n\n## Internal testing notice\n\nDeepSeek Harness is under internal testing. Features and interfaces may change.\n\nThe internal build uploads all Session Logs by default to help diagnose reported problems. Set `DSH_TELEMETRY_DISABLED=1` to disable telemetry. Send feedback through the internal WeChat group.\n\n## Install\n\nClone the repository, then run the installer:\n\n```sh\ngit clone \ncd deepseek-harness\nscripts/install.sh\n```\n\nThe installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, prompts for a DeepSeek API key, builds the required repository artifacts, and launches the Web UI.\n\nThe default active checkout is `~/.dsh/source/current`, and the launcher is linked into `~/.local/bin`. Re-run the installer to update. [`scripts/install.sh`](scripts/install.sh) owns alternate locations, update mechanics, and recovery options.\n\n## Use DeepSeek Harness\n\n### Web UI\n\nFor the recommended local interface, choose Web UI when the installer finishes. To start it later, or after updating the active checkout, build the repository and run:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\nThe path above is the installer's default. If you set `DSH_SOURCE` or `DSH_CURRENT`, or reused an existing checkout, replace `~/.dsh/source/current` with that checkout path; see [`scripts/install.sh`](scripts/install.sh) for details. The Web UI is served at `http://127.0.0.1:3080` by default.\n\n### Profiles\n\n`dsh` boots profiles — ordered stacks of plugin-bundle patch layers under your own overrides in `$DSH_HOME/profiles/`:\n\n```sh\ndsh --profile web # the browser UI (same as: dsh web)\ndsh plugin --profile tui add # install a plugin into a custom profile\ndsh --profile tui # boot it\n```\n\nThe [CLI contract](apps/cli/README.md#profiles) describes profile layout, layer semantics, and config dump commands.\n\n### Headless\n\nRun one task, print the final answer, and exit:\n\n```sh\ndsh run \"summarize this workspace\"\n```\n\n### Automation and SDKs\n\nFrom a source checkout with `DEEPSEEK_API_KEY` in the environment or its root `.env`, start the ACP automation server:\n\n```sh\npnpm run demo:acp\n```\n\nThe [Python SDK](python/README.md) drives a bundled JSON-RPC runtime. The [examples](examples/README.md) cover the runnable headless, ACP, JSON-RPC, Code Mode, and self-referential compositions.\n\n## Why DeepSeek Harness\n\nBuilt-in capabilities cover file reading, editing, and search; shell and persistent PTY execution; reusable skills; task tracking, goals, plans, todos, and background tasks; subagents and workflows; sandboxing and approvals; settings and credentials; persistent, resumable, forkable, and queryable sessions; LSP and web access; context compaction; and telemetry. Each composition selects the subset appropriate to its surface. The Web UI includes Plan Mode.\n\n- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design.\n- **Runs are reconstructable.** Anything visible to the model is logged in the authoritative session stream; persistence, resume/fork/query, replay, telemetry, and UIs derive from the same events. See the [session-log architecture](docs/architecture.md#session-log).\n- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode).\n- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/cordis/tool-cordis/README.md).\n\n## Community\n\nFollow DeepSeek Harness on Twitter for project updates.\n\n## Development\n\nStart with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\nDeepSeek Harness is currently in internal testing.\n\n## License\n\n[BSD 3-Clause](LICENSE)\n\nThird-party dependencies and their licenses are disclosed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).\n" }, { "role": "assistant", - "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(编程智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 内测声明\n\nDeepSeek Harness 正处于内部测试阶段,功能和接口可能发生变化。\n\n为帮助诊断上报的问题,内测版本默认上传所有会话日志。设置 `DSH_TELEMETRY_DISABLED=1` 可关闭遥测。请通过内部企业微信群反馈问题和建议。\n\n## 安装\n\n克隆仓库,然后运行安装器:\n\n```sh\ngit clone \ncd deepseek-harness\nscripts/install.sh\n```\n\n安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥,然后构建所需的仓库产物并启动 Web UI。\n\n默认生效的检出位于 `~/.dsh/source/current`,启动器链接到 `~/.local/bin`。再次运行安装器即可更新。其他位置、更新机制和恢复选项由 [`scripts/install.sh`](scripts/install.sh) 负责。\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n推荐在本地使用 Web UI;安装结束时,选择 Web UI 即可。以后需要启动时,或更新当前生效的检出后,请构建仓库并运行:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\n上述路径是安装器的默认位置。如果你设置过 `DSH_SOURCE` 或 `DSH_CURRENT`,或者复用了已有检出,请把 `~/.dsh/source/current` 换成该检出路径;详情见 [`scripts/install.sh`](scripts/install.sh)。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。\n\n### Profile\n\n`dsh` 启动 profile:按序叠放的插件组合包 patch 层,之上再叠加你在 `$DSH_HOME/profiles/` 中的自有覆盖层:\n\n```sh\ndsh --profile web # the browser UI (same as: dsh web)\ndsh plugin --profile tui add # install a plugin into a custom profile\ndsh --profile tui # boot it\n```\n\nprofile 布局、层语义与配置输出命令详见 [CLI(命令行界面)契约](apps/cli/README.md#profiles)。\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\ndsh --profile headless \"summarize this workspace\"\n```\n\n### 自动化与 SDK\n\n在源码检出中通过环境变量或根目录 `.env` 设置 `DEEPSEEK_API_KEY`,然后启动 ACP(Agent Client Protocol)自动化服务器:\n\n```sh\npnpm run demo:acp\n```\n\n[Python SDK](python/README.md) 驱动随附的 JSON-RPC 运行时。[示例](examples/README.md)涵盖可运行的 headless、ACP、JSON-RPC、Code Mode 和自指组合。\n\n## 为什么选择 DeepSeek Harness\n\n内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill(技能)、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、设置与凭据、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩(context compaction),以及遥测。每个组合只选用适合其使用方式的能力子集。Web UI 包含 Plan Mode。\n\n- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均可组合为 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。\n- **运行可重建。** 凡是模型可见的内容,都会记录在权威会话流中;持久化、恢复/fork/查询、回放、遥测和 UI 均从同一组事件派生。参见[会话日志架构](docs/architecture.md#session-log)。\n- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。\n- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/cordis/tool-cordis/README.md)。\n\n## 社区\n\n扫描二维码,或打开 DeepSeek Harness 微信社区申请页面 申请加入。\n\n

\n \"DeepSeek\n

\n\n## 开发\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\nDeepSeek Harness 目前处于内测阶段。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n\n第三方依赖及其许可证在 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) 中披露。\n" + "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(编程智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 内测声明\n\nDeepSeek Harness 正处于内部测试阶段,功能和接口可能发生变化。\n\n为帮助诊断上报的问题,内测版本默认上传所有会话日志。设置 `DSH_TELEMETRY_DISABLED=1` 可关闭遥测。请通过内部企业微信群反馈问题和建议。\n\n## 安装\n\n克隆仓库,然后运行安装器:\n\n```sh\ngit clone \ncd deepseek-harness\nscripts/install.sh\n```\n\n安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥,然后构建所需的仓库产物并启动 Web UI。\n\n默认生效的检出位于 `~/.dsh/source/current`,启动器链接到 `~/.local/bin`。再次运行安装器即可更新。其他位置、更新机制和恢复选项由 [`scripts/install.sh`](scripts/install.sh) 负责。\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n推荐在本地使用 Web UI;安装结束时,选择 Web UI 即可。以后需要启动时,或更新当前生效的检出后,请构建仓库并运行:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\n上述路径是安装器的默认位置。如果你设置过 `DSH_SOURCE` 或 `DSH_CURRENT`,或者复用了已有检出,请把 `~/.dsh/source/current` 换成该检出路径;详情见 [`scripts/install.sh`](scripts/install.sh)。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。\n\n### Profile\n\n`dsh` 启动 profile:按序叠放的插件组合包 patch 层,之上再叠加你在 `$DSH_HOME/profiles/` 中的自有覆盖层:\n\n```sh\ndsh --profile web # the browser UI (same as: dsh web)\ndsh plugin --profile tui add # install a plugin into a custom profile\ndsh --profile tui # boot it\n```\n\nprofile 布局、层语义与配置输出命令详见 [CLI(命令行界面)契约](apps/cli/README.md#profiles)。\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\ndsh run \"summarize this workspace\"\n```\n\n### 自动化与 SDK\n\n在源码检出中通过环境变量或根目录 `.env` 设置 `DEEPSEEK_API_KEY`,然后启动 ACP(Agent Client Protocol)自动化服务器:\n\n```sh\npnpm run demo:acp\n```\n\n[Python SDK](python/README.md) 驱动随附的 JSON-RPC 运行时。[示例](examples/README.md)涵盖可运行的 headless、ACP、JSON-RPC、Code Mode 和自指组合。\n\n## 为什么选择 DeepSeek Harness\n\n内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill(技能)、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、设置与凭据、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩(context compaction),以及遥测。每个组合只选用适合其使用方式的能力子集。Web UI 包含 Plan Mode。\n\n- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均可组合为 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。\n- **运行可重建。** 凡是模型可见的内容,都会记录在权威会话流中;持久化、恢复/fork/查询、回放、遥测和 UI 均从同一组事件派生。参见[会话日志架构](docs/architecture.md#session-log)。\n- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。\n- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/cordis/tool-cordis/README.md)。\n\n## 社区\n\n扫描二维码,或打开 DeepSeek Harness 微信社区申请页面 申请加入。\n\n

\n \"DeepSeek\n

\n\n## 开发\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\nDeepSeek Harness 目前处于内测阶段。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n\n第三方依赖及其许可证在 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) 中披露。\n" }, { "role": "user", From a8879430f0407bc12b120d9bc5efcff400ab6175 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 14:20:06 +0800 Subject: [PATCH 080/109] ci: harden private repository link gate --- .../verify-public-repository-links.spec.ts | 21 ++++++++++++---- scripts/verify-public-repository-links.ts | 24 ++++++++++++++++++- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/scripts/verify-public-repository-links.spec.ts b/scripts/verify-public-repository-links.spec.ts index 615bfa68e2..ec12f38427 100644 --- a/scripts/verify-public-repository-links.spec.ts +++ b/scripts/verify-public-repository-links.spec.ts @@ -2,18 +2,31 @@ import { describe, expect, it } from 'vitest' import { findInternalRepositoryReferences } from './verify-public-repository-links.ts' describe('public repository link policy', () => { - it('rejects internal repository references and accepts the public home', () => { + it('rejects encoded and case-varied internal identities without blocking public repositories', () => { const internalOwner = ['deepseek', 'harness'].join('-') const internalRepository = [internalOwner, internalOwner].join('/') + const encodedRepository = internalRepository.replaceAll('-', '%2D').replace('/', '%2F') + const htmlEncodedRepository = internalRepository.replace('/', '/') + const jsonEscapedRepository = internalRepository.replace('/', '\\/') + const unicodeEscapedRepository = internalRepository.replace('/', String.raw`\u002f`) const source = [ 'https://github.com/deepseek-ai/deepseek-harness-sdk', - `https://github.com/${internalRepository}/issues/1`, - `${internalOwner}#2`, + `https://github.com/${internalOwner}/cordis`, + `https://github.com/${internalRepository.toUpperCase()}/issues/1`, + `https://github.com/${encodedRepository}/issues/2`, + `https://github.com/${htmlEncodedRepository}/issues/3`, + `"https:\\/\\/github.com\\/${jsonEscapedRepository}\\/issues\\/4"`, + `"https:\\/\\/github.com\\/${unicodeEscapedRepository}\\/issues\\/5"`, + `${internalOwner.toUpperCase()}#6`, ].join('\n') expect(findInternalRepositoryReferences('subject.md', source)).toEqual([ - { file: 'subject.md', line: 2 }, { file: 'subject.md', line: 3 }, + { file: 'subject.md', line: 4 }, + { file: 'subject.md', line: 5 }, + { file: 'subject.md', line: 6 }, + { file: 'subject.md', line: 7 }, + { file: 'subject.md', line: 8 }, ]) }) }) diff --git a/scripts/verify-public-repository-links.ts b/scripts/verify-public-repository-links.ts index a57628e00c..6d1e537733 100644 --- a/scripts/verify-public-repository-links.ts +++ b/scripts/verify-public-repository-links.ts @@ -10,6 +10,27 @@ const internalOwner = ['deepseek', 'harness'].join('-') const internalRepository = [internalOwner, internalOwner].join('/') const internalIssueShorthand = `${internalOwner}#` +const namedReferenceCharacters: Readonly> = { + hyphen: '-', + num: '#', + sol: '/', +} + +/** Normalize source spellings that render or decode to repository separators. */ +function canonicalReferenceText(source: string): string { + return source + .replaceAll('\\/', '/') + .replace(/\\u(0023|002d|002f)/gi, (_match, code: string) => String.fromCodePoint(Number.parseInt(code, 16))) + .replace(/%(23|2d|2f)/gi, (_match, code: string) => String.fromCodePoint(Number.parseInt(code, 16))) + .replace(/&#(?:(\d+)|x([\da-f]+));/gi, (entity, decimal: string | undefined, hexadecimal: string | undefined) => { + const code = Number.parseInt(decimal ?? hexadecimal ?? '', decimal === undefined ? 16 : 10) + return code === 35 || code === 45 || code === 47 ? String.fromCodePoint(code) : entity + }) + .replace(/&(hyphen|num|sol);/gi, (entity, name: string) => namedReferenceCharacters[name.toLowerCase()] ?? entity) + .normalize('NFKC') + .toLowerCase() +} + /** One tracked reference to the internal repository. */ export interface InternalRepositoryReference { /** Repository-relative file path. */ @@ -27,7 +48,8 @@ export interface InternalRepositoryReference { export function findInternalRepositoryReferences(file: string, source: string): InternalRepositoryReference[] { const references: InternalRepositoryReference[] = [] for (const [index, line] of source.split('\n').entries()) { - if (line.includes(internalRepository) || line.includes(internalIssueShorthand)) { + const canonicalLine = canonicalReferenceText(line) + if (canonicalLine.includes(internalRepository) || canonicalLine.includes(internalIssueShorthand)) { references.push({ file, line: index + 1 }) } } From 63f88997bb796a492b02a322733c2e0c87fc0d5b Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 14:36:03 +0800 Subject: [PATCH 081/109] fix(web): preserve compact icon until hover --- .../client/ui-conversation/README.i18n.yaml | 4 +-- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../src/client/chat/CompactionItem.tsx | 13 ++++++-- .../src/client/chat/MessageItem.module.css | 33 +++++++++++++++---- .../ui-conversation/tests/chat-view.spec.tsx | 3 ++ 6 files changed, 45 insertions(+), 12 deletions(-) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index ca3289ba55..d29623d0f2 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/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-conversation/README.md -README.md: 5b37065097ef60c2edf14725f4e1e1c6a52c4366 -README.zh.md: 4ec26155a124497db0fc7f351d20ecb451a18763 +README.md: 985c78e97a8c7f46451252e67095c91e990aa694 +README.zh.md: 36a2f7a13c3f60692f9547ccf5a51ad6356d26d8 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 5b37065097..985c78e97a 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation, an animated left-to-right gradient `Deep diving...` turn status, per-tool row slot with a bash sample registrant and the todo row), composer dock (session stats sticky with the input), input dock (hairline-separated queue rows plus the todo plan strip), minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares). -Compaction renders as one collapsed row at the checkpoint's flow position without replacing the transcript above it. Automatic compaction uses the context-compacted title. Manual `/compact` starts as a running `compact` row; on successful settlement its explicit summary-event reference folds that command into the checkpoint row under the same React key, showing the replaced-item and estimated-token counts and disclosing the summary on click. Input rejection, no compactable history, cancellation, and failure retain the generic command row and its handler-authored text. Pairing never depends on adjacency because durable context may be injected while compaction is running. The framed checkpoint payload is model-facing and never renders; when summary provenance is outside the loaded window, the checkpoint remains visible but non-expandable. +Compaction renders as one collapsed row at the checkpoint's flow position without replacing the transcript above it. Automatic compaction uses the context-compacted title. Manual `/compact` starts as a running `compact` row; on successful settlement its explicit summary-event reference folds that command into the checkpoint row under the same React key, showing the replaced-item and estimated-token counts and disclosing the summary on click. A completed checkpoint keeps the context-compaction icon at rest and replaces it with the collapsed or expanded disclosure only on hover or keyboard focus. Input rejection, no compactable history, cancellation, and failure retain the generic command row and its handler-authored text. Pairing never depends on adjacency because durable context may be injected while compaction is running. The framed checkpoint payload is model-facing and never renders; when summary provenance is outside the loaded window, the checkpoint remains visible but non-expandable. The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. The root always owns the same scrollport and Hero/composer subtree; separate strict-session header and body outlets fill their regions when the first Session arrives, so the Workspace picker, scroll body, composer seat, and textarea retain their React and DOM identity. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header shows only the current session title and view tabs as ordinary column chrome; fork lineage remains session data and is not projected into the header. Beneath it the scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). That scrollport reserves its scrollbar gutter unconditionally, and a view opting into a composer overlay leaves it a scroll container, so the input card keeps one horizontal position whether or not the transcript scrolls and whichever view tab is shown ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 4ec26155a1..36a2f7a13c 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -4,7 +4,7 @@ 会话领域:骨架(标题栏/标签页/编辑器/空状态)、聊天视图(分组步骤摘要流、流式尾部隔离、带从左到右动态渐变的 `Deep diving...` 轮次状态、逐工具行 slot 及一个 bash 示例注册方与 todo 行)、编辑器 dock(与输入区一同 sticky 的会话统计行)、输入区 dock(带发丝分界线的队列行加 todo 计划条)、最小详情面板、按 scope 寻址的 ConversationService。契约:api-contracts v3 §7 加 slot 终端设计(store seat/props share)。 -压缩(compaction)在检查点自身的消息流位置渲染为一行折叠标记,不替换其上方的 transcript(文本记录)。自动压缩使用「上下文已压缩」标题。手动 `/compact` 开始时显示为运行中的 `compact` 行;成功结算后,其显式摘要事件引用会在保持同一 React key 的前提下把该命令折叠进检查点行,显示被替换条目数量和估算 token 数量,并可点击展开摘要。输入被拒绝、没有可压缩历史、取消和失败时仍使用通用命令行及处理器撰写的文本。配对绝不依赖相邻关系,因为压缩运行期间可能注入持久上下文。面向模型的带框检查点载荷绝不渲染;摘要溯源位于已加载窗口之外时,检查点仍然可见但不可展开。 +压缩(compaction)在检查点自身的消息流位置渲染为一行折叠标记,不替换其上方的 transcript(文本记录)。自动压缩使用「上下文已压缩」标题。手动 `/compact` 开始时显示为运行中的 `compact` 行;成功结算后,其显式摘要事件引用会在保持同一 React key 的前提下把该命令折叠进检查点行,显示被替换条目数量和估算 token 数量,并可点击展开摘要。完成的检查点静止时保留上下文压缩图标,仅在悬停或键盘聚焦时将其替换为收起/展开指示图标。输入被拒绝、没有可压缩历史、取消和失败时仍使用通用命令行及处理器撰写的文本。配对绝不依赖相邻关系,因为压缩运行期间可能注入持久上下文。面向模型的带框检查点载荷绝不渲染;摘要溯源位于已加载窗口之外时,检查点仍然可见但不可展开。 常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。根组件始终拥有同一个滚动容器与 Hero/编辑器子树;首个会话到达时,彼此独立的严格会话页头和主体 outlet 只填入各自区域,因此 Workspace 选择器、滚动主体、编辑器 seat 与 textarea 都保留原有 React 和 DOM identity。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段,会话标题栏作为普通列 chrome,仅显示当前会话标题和视图标签;fork 谱系仍保留为会话数据,不投影到标题栏。其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。该滚动容器无条件预留自己的滚动条槽,选用编辑器 overlay 的视图也仍把它保留为滚动容器,因此无论对话记录是否滚动、无论展示哪个视图标签,输入卡片都保持同一个横向位置([决策](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md))。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。 diff --git a/packages/client/ui-conversation/src/client/chat/CompactionItem.tsx b/packages/client/ui-conversation/src/client/chat/CompactionItem.tsx index 7049688cc0..5e5f0c87b7 100644 --- a/packages/client/ui-conversation/src/client/chat/CompactionItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/CompactionItem.tsx @@ -9,6 +9,7 @@ import { memo, useState } from 'react' import type { CompactionSummaryNode } from '@deepseek-ai/dsh-client-runtime/client' import { + IconApiOutline14, IconChevronDownOutline14, IconChevronRightOutline14, MarkdownText, @@ -56,8 +57,16 @@ export const CompactionItem = memo(function CompactionItem({ aria-expanded={expandable ? open : undefined} onClick={() => { setExpanded(value => !value) }} > - - {open ? : } + + + + + + {open ? : } + {title ?? t('message.compaction')} diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css index 5c07ace71e..c6ca35bb2c 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css @@ -33,9 +33,9 @@ padding: 2px 0; } -/* Compaction marker: one dim 24px row with a chevron disclosure for the - summary body. Dimmed title (not label-primary) — the row is a boundary - notice, not conversation content. */ +/* Compaction marker: one dim 24px row with a context icon at rest and a + hover/focus disclosure for the summary body. Dimmed title (not + label-primary) — the row is a boundary notice, not conversation content. */ .compactionRow { padding: 2px 0; } @@ -65,15 +65,36 @@ .compactionLeading { flex: none; - display: inline-flex; - align-items: center; - justify-content: center; + display: inline-grid; + place-items: center; width: 16px; height: 16px; margin-right: 6px; color: var(--dsw-alias-label-secondary); } +.compactionContextIcon, +.compactionDisclosureIcon { + display: inline-flex; + grid-area: 1 / 1; + align-items: center; + justify-content: center; +} + +.compactionDisclosureIcon { + opacity: 0; +} + +.compactionButton:not(:disabled):hover .compactionContextIcon, +.compactionButton:not(:disabled):focus-visible .compactionContextIcon { + opacity: 0; +} + +.compactionButton:not(:disabled):hover .compactionDisclosureIcon, +.compactionButton:not(:disabled):focus-visible .compactionDisclosureIcon { + opacity: 1; +} + .compactionTitle { flex: none; font-size: 14px; diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index a0213a2407..b16a0b9317 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -1303,9 +1303,12 @@ describe('ChatView', () => { expect(view.getByText('已压缩 16 条历史记录(约 11309 tokens)')).toBeTruthy() const row = view.getByRole('button', { name: /compact/ }) expect(row.getAttribute('aria-expanded')).toBe('false') + expect(row.querySelector('[data-compaction-icon="context"]')).not.toBeNull() + expect(row.querySelector('[data-compaction-disclosure="collapsed"]')).not.toBeNull() expect(view.queryByText('保留的事实。')).toBeNull() fireEvent.click(row) expect(row.getAttribute('aria-expanded')).toBe('true') + expect(row.querySelector('[data-compaction-disclosure="expanded"]')).not.toBeNull() expect(view.getByRole('heading', { name: '压缩摘要' })).toBeTruthy() }) From 04e6a9806450426203a71e316a3398ee0f358ddd Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 14:42:07 +0800 Subject: [PATCH 082/109] feat(client): surface subagent activity in sidebar --- ...07-27-web-subagent-conversations.i18n.yaml | 4 +- .../2026-07-27-web-subagent-conversations.md | 6 +- ...026-07-27-web-subagent-conversations.zh.md | 6 +- .../sidebar-running.expected.md | 5 ++ apps/web/tests/subagent-conversation.e2e.ts | 10 +++ packages/client/runtime/README.i18n.yaml | 4 +- packages/client/runtime/README.md | 2 + packages/client/runtime/README.zh.md | 2 + packages/client/runtime/src/client/index.ts | 2 + .../src/client/sessions/subagent-lineage.ts | 50 +++++++++++ .../runtime/tests/subagent-lineage.spec.ts | 53 ++++++++++++ .../src/client/SubagentCatalogAction.tsx | 46 +++------- packages/client/ui-workspace/README.i18n.yaml | 4 +- packages/client/ui-workspace/README.md | 2 +- packages/client/ui-workspace/README.zh.md | 2 +- .../client/ui-workspace/src/client/locales.ts | 4 + .../ui-workspace/src/client/rows/Rows.tsx | 86 +++++++++++++------ .../client/ui-workspace/src/client/tree.ts | 25 ++++-- .../client/ui-workspace/tests/rows.spec.tsx | 72 +++++++++++++--- .../client/ui-workspace/tests/tree.spec.ts | 28 +++++- 20 files changed, 320 insertions(+), 93 deletions(-) create mode 100644 apps/web/tests/snapshots/subagent-conversation/sidebar-running.expected.md create mode 100644 packages/client/runtime/src/client/sessions/subagent-lineage.ts create mode 100644 packages/client/runtime/tests/subagent-lineage.spec.ts diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml index abb388a410..ac7fc12150 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.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-27-web-subagent-conversations.md -2026-07-27-web-subagent-conversations.md: 03c805d30dbdbbdb33d0b06ba8036ec181035ac6 -2026-07-27-web-subagent-conversations.zh.md: e83f07fb21f58ad175ab3e5638981648fa4cef05 +2026-07-27-web-subagent-conversations.md: 60717365244ff3ca3ebc4a400b15bfc81062212c +2026-07-27-web-subagent-conversations.zh.md: 217ed44249c0d1beb731e24e52054f200fae4b9b diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md index 03c805d30d..6071736524 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md @@ -37,7 +37,7 @@ The Figma [subagent list](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Ha ## Product contract -The header action is absent only when a complete empty direct-catalog response agrees with the session-summary projection that no subagent descendants are known. Its trigger counts every known session-summary descendant reached through an uninterrupted `origin: 'subagent'` lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. Every healthy direct-catalog row carries a read-time `hasChildren` hint derived only from direct lineage headers with durable `origin: 'subagent'`; normal healthy and diagnostic subagent candidates carry that marker, while ordinary forks do not. This lookahead reads no descendant event log, and the descriptor-backed catalog loaded after disclosure remains authoritative. When summaries establish descendants before that catalog exists or after a stale empty response, the action stays visible and exposes only disabled loading rows until opening it refreshes the catalog; summary-only rows never grant navigation. The UI omits disclosure for a known leaf before interaction; the hint does not promise that the child will remain a leaf. While an expanded direct catalog is loading, known lineage reserves one disabled loading row per direct descendant without recursively fetching descendant catalogs. The tree then presents continuable and one-shot rows, falling back to the session id when an optional one-shot label is absent. Corrupt, unsupported, and unavailable candidates remain visible as disabled diagnostic rows. +The header action is absent only when a complete empty direct-catalog response agrees with the session-summary projection that no subagent descendants are known. Its trigger counts every known session-summary descendant reached through an uninterrupted `origin: 'subagent'` lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. Because ordinary sidebar rows hide subagent-origin sessions, the Workspace browser indexes the same uninterrupted lineage onto each visible ordinary row: any running descendant supplies its blue activity indicator and exact count in hover and assistive text without describing an idle parent as running. An ordinary fork starts a separate aggregation subtree. Parent running and pending interaction remain distinct primary statuses; descendant activity becomes a second hover and assistive status when either is present. Every healthy direct-catalog row carries a read-time `hasChildren` hint derived only from direct lineage headers with durable `origin: 'subagent'`; normal healthy and diagnostic subagent candidates carry that marker, while ordinary forks do not. This lookahead reads no descendant event log, and the descriptor-backed catalog loaded after disclosure remains authoritative. When summaries establish descendants before that catalog exists or after a stale empty response, the action stays visible and exposes only disabled loading rows until opening it refreshes the catalog; summary-only rows never grant navigation. The UI omits disclosure for a known leaf before interaction; the hint does not promise that the child will remain a leaf. While an expanded direct catalog is loading, known lineage reserves one disabled loading row per direct descendant without recursively fetching descendant catalogs. The tree then presents continuable and one-shot rows, falling back to the session id when an optional one-shot label is absent. Corrupt, unsupported, and unavailable candidates remain visible as disabled diagnostic rows. `running` means the exact child Agent driver is draining work at the Host sampling boundary; `inactive` means that driver is idle or absent. The UI does not translate either value into success, failure, cancellation, completeness, or resumability. `subagent.list` supplies the current driver-status baseline, `host/session-status` updates known activity in place, request-local replay prevents an older in-flight list response from overwriting a newer transition, and `host/session-removed` returns a known row to `inactive`; reconnect reads a fresh baseline. A `host/session-added` frame for a direct subagent immediately flips any loaded parent row to `hasChildren: true`, and that positive hint survives an older in-flight catalog response; membership, labels, mode, diagnostics, and the authoritative snapshot still require a debounced `subagent.list` refresh while the affected branch is open. A prompt response remains delivery-time authority. @@ -104,8 +104,8 @@ The shipped Web composition mounts SQLite session query beside JSONL persistence - Host protocol tests pin schemas including required boolean expandability, id echoing, mode verification, non-activating history, exact-parent enforcement, FIFO admission receipts, cancellation, and sanitized failure mapping. - Generic Host tests pin attached and cold history and forks without Agent publication, cold projection folding, descriptor/origin/runtime-owner denial, explicit-id adoption denial, and the direct queue-control fence. - Client object tests pin retained and restored addresses, one-shot read-only rejection, history routing, continuable prompt routing, no addressed cancellation, suppression of Agent-bound model controls, live activity flips including in-flight response replay and detach fallback, subagent-parent expandability flips, and membership refresh. -- jsdom tests pin the aggregate descendant count and activity, token totals, second-precision running and frozen inactive durations, adaptive long-duration units with exact accessible text, the summary-backed root action across absent and stale-empty catalogs, known loading-row shape, mixed-mode rows, pre-click leaf disclosure, diagnostics, lazy descendant disclosure, direct-parent addresses, keyboard behavior, and both read-only reasons. -- The keyless assembled Web snapshot contains an inactive continuable child with durable usage, an inactive one-shot sibling with a deterministic long duration, and a persisted grandchild; it pins the three-descendant trigger across a stale empty catalog response, usage and timing rows, adaptive long-duration presentation, and aggregate running transition, expands without activation, opens persisted history, admits a human FIFO follow-up, reconciles child mux events, and proves one-shot history remains read-only. +- jsdom tests pin the aggregate descendant count and activity, sidebar propagation across nested lineage and ordinary-fork boundaries, row-status precedence, token totals, second-precision running and frozen inactive durations, adaptive long-duration units with exact accessible text, the summary-backed root action across absent and stale-empty catalogs, known loading-row shape, mixed-mode rows, pre-click leaf disclosure, diagnostics, lazy descendant disclosure, direct-parent addresses, keyboard behavior, and both read-only reasons. +- The keyless assembled Web snapshot contains an inactive continuable child with durable usage, an inactive one-shot sibling with a deterministic long duration, and a persisted grandchild; it pins the three-descendant trigger across a stale empty catalog response, usage and timing rows, adaptive long-duration presentation, aggregate running transition in the header and owner sidebar row, expands without activation, opens persisted history, admits a human FIFO follow-up, reconciles child mux events, and proves one-shot history remains read-only. - Navigation tests pin subagent-only breadcrumbs, workspace placement for forks created from subagents, and `origin: 'subagent'` sidebar filtering without hiding ordinary forks. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md index e83f07fb21..217ed44249 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md @@ -37,7 +37,7 @@ Figma 中的 [subagent 列表](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5 ## 产品契约 -只有当完整的直接目录空响应与会话摘要投影相符,二者均表明没有已知的 subagent 后代时,才不显示页头操作。其触发器会统计经不间断的 `origin: 'subagent'` 谱系可达的每个已知会话摘要后代,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。每个健康的直接目录行都携带读取时的 `hasChildren` 提示,该值只根据持久化 `origin: 'subagent'` 的直接谱系 header 派生;正常的健康与 diagnostic subagent 候选都会携带该标记,而普通 fork 不会。该预查不读取任何后代事件日志,展开后仍以描述符支撑的目录为权威依据。当摘要在该目录尚不存在时或在一次陈旧的空响应后确认已有后代时,该操作会保持可见,并且在打开它以刷新目录之前仅显示禁用的加载行;仅由摘要支撑的行绝不会提供导航能力。UI 会在交互前就省略已知叶子节点的展开控件;该提示不承诺 child 会一直是叶子。已展开的直接目录加载期间,已知谱系会为每个直接后代预留一行禁用的加载行,而不会递归获取后代目录。随后树会呈现可继续与 one-shot 行;one-shot 的可选 label 缺失时,回退到其会话 id。损坏、不受支持或不可用的候选仍以禁用的 diagnostic 行显示。 +只有当完整的直接目录空响应与会话摘要投影相符,二者均表明没有已知的 subagent 后代时,才不显示页头操作。其触发器会统计经不间断的 `origin: 'subagent'` 谱系可达的每个已知会话摘要后代,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。由于普通侧边栏行会隐藏 origin 为 subagent 的会话,Workspace 浏览器会在每个可见的普通行上索引同一条不间断谱系:任何运行中的后代都会让该行显示蓝色活动指示器,并在悬停与无障碍文本中给出确切数量,同时不会把空闲 parent 描述为正在运行。普通 fork 会开启单独的聚合子树。parent 的运行中状态与待处理交互仍是彼此不同的主要状态;只要其中任一存在,后代活动就成为悬停与无障碍状态中的第二项。每个健康的直接目录行都携带读取时的 `hasChildren` 提示,该值只根据持久化 `origin: 'subagent'` 的直接谱系 header 派生;正常的健康与 diagnostic subagent 候选都会携带该标记,而普通 fork 不会。该预查不读取任何后代事件日志,展开后仍以描述符支撑的目录为权威依据。当摘要在该目录尚不存在时或在一次陈旧的空响应后确认已有后代时,该操作会保持可见,并且在打开它以刷新目录之前仅显示禁用的加载行;仅由摘要支撑的行绝不会提供导航能力。UI 会在交互前就省略已知叶子节点的展开控件;该提示不承诺 child 会一直是叶子。已展开的直接目录加载期间,已知谱系会为每个直接后代预留一行禁用的加载行,而不会递归获取后代目录。随后树会呈现可继续与 one-shot 行;one-shot 的可选 label 缺失时,回退到其会话 id。损坏、不受支持或不可用的候选仍以禁用的 diagnostic 行显示。 `running` 表示在 Host 采样边界,确切 child Agent driver 正在处理工作;`inactive` 表示该 driver 空闲或不存在。UI 不会把任一值解释为成功、失败、取消、完成状态或可恢复性。`subagent.list` 提供当前 driver 状态基线,`host/session-status` 会就地更新已知活动状态,请求内回放会阻止更早发起但尚未完成的列表响应覆盖较新的状态转换,`host/session-removed` 则会使已知行恢复为 `inactive`;重连时会读取新的基线。直接 subagent 的 `host/session-added` 帧会立即把任何已加载的 parent 行翻转为 `hasChildren: true`,并使这项正向提示不被更早发起但尚未完成的目录响应覆盖;受影响分支打开期间,成员、label、mode、diagnostic 与权威快照仍需要通过去抖动的 `subagent.list` 刷新来更新。消息投递时仍以提示词响应为权威依据。 @@ -104,8 +104,8 @@ one-shot 行始终会用文案替代输入框,说明执行记录为只读。 - 宿主协议测试固定 schema(包括必需的布尔可展开性)、id 回显、mode 校验、非激活式历史、确切 parent 强制要求、FIFO 准入回执、取消与脱敏后的失败映射。 - 通用 Host 测试固定在不发布 Agent 的情况下读取已附加与冷态历史及执行 fork、冷态投影归并、按描述符/origin/运行时 owner 拒绝、拒绝显式 id 接纳,以及直接队列控制栅栏。 - 客户端对象测试固定已保留与已恢复的地址、one-shot 只读拒绝、历史路由、可继续提示词路由、已寻址对话不提供取消、屏蔽绑定到 agent 的模型控件、实时活动状态翻转(包括在途响应回放与 detach 回退)、subagent parent 可展开性翻转与成员刷新。 -- jsdom 测试固定后代聚合计数与活动状态、token 用量总计、精确到秒的运行中耗时与冻结后 inactive 耗时、采用自适应单位的长耗时及其精确无障碍文本、目录缺失或为陈旧空目录时由摘要支撑的根操作、已知加载行的形态、混合 mode 行、点击前的叶子展开控件、diagnostic、后代懒加载展开、直接 parent 地址、键盘行为与两种只读原因。 -- 无密钥的组装 Web 快照包含一个具有持久化 token 用量的 inactive 可继续 child、一个具有确定性长耗时的 inactive one-shot sibling 和一个持久化 grandchild;它会固定触发器在一次陈旧的空目录响应后仍显示三个后代,并固定 token 用量与计时行、自适应长耗时呈现以及聚合 `running` 状态转换,在不激活的情况下展开、打开持久化历史、准入一条用户 FIFO 后续消息、归并 child mux 事件,并证明 one-shot 历史仍然只读。 +- jsdom 测试固定后代聚合计数与活动状态、侧边栏活动在嵌套谱系中的传播与普通 fork 边界、行状态优先级、token 用量总计、精确到秒的运行中耗时与冻结后 inactive 耗时、采用自适应单位的长耗时及其精确无障碍文本、目录缺失或为陈旧空目录时由摘要支撑的根操作、已知加载行的形态、混合 mode 行、点击前的叶子展开控件、diagnostic、后代懒加载展开、直接 parent 地址、键盘行为与两种只读原因。 +- 无密钥的组装 Web 快照包含一个具有持久化 token 用量的 inactive 可继续 child、一个具有确定性长耗时的 inactive one-shot sibling 和一个持久化 grandchild;它会固定触发器在一次陈旧的空目录响应后仍显示三个后代,并固定 token 用量与计时行、自适应长耗时呈现以及页头和 owner 侧边栏行中的聚合 `running` 状态转换,在不激活的情况下展开、打开持久化历史、准入一条用户 FIFO 后续消息、归并 child mux 事件,并证明 one-shot 历史仍然只读。 - 导航测试固定仅含 subagent 的面包屑导航、从 subagent 创建 fork 时的 Workspace 归属,以及 `origin: 'subagent'` 侧边栏过滤,同时不隐藏普通 fork。 ## 后果 diff --git a/apps/web/tests/snapshots/subagent-conversation/sidebar-running.expected.md b/apps/web/tests/snapshots/subagent-conversation/sidebar-running.expected.md new file mode 100644 index 0000000000..fec6fe658e --- /dev/null +++ b/apps/web/tests/snapshots/subagent-conversation/sidebar-running.expected.md @@ -0,0 +1,5 @@ +- tree "Sessions": + - treeitem "workspace 1 session" [expanded]: + - img + - text: workspace 1 session + - treeitem "1 subagent running Ask a research subagent to now" [selected] diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index 0049cb2791..69250ed6b6 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -23,6 +23,7 @@ const TREE_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/t const BRANCHLESS_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/branchless.expected.md', import.meta.url)) const STALE_CATALOG_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/stale-catalog.expected.md', import.meta.url)) const SIDEBAR_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/sidebar.expected.md', import.meta.url)) +const RUNNING_SIDEBAR_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/sidebar-running.expected.md', import.meta.url)) const UNAVAILABLE_GRANDCHILD_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/nested.expected.md', import.meta.url)) const FORK_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/fork.expected.md', import.meta.url)) const MODE = webSnapshotMode() @@ -367,6 +368,15 @@ describe('web e2e: persisted subagent conversation and human continuation', () = ).toBe('running') const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' }) await hierarchy.getByRole('button').first().click() + const runningOwnerRow = page.getByRole('tree', { name: 'Sessions' }) + .getByRole('treeitem', { name: /1 subagent running/ }) + await runningOwnerRow.waitFor({ timeout: 10_000 }) + expect(await runningOwnerRow.locator('[data-state="ongoing"]').count()).toBe(1) + await compareOrRefreshGolden( + RUNNING_SIDEBAR_EXPECTED, + await captureStableAria(page, '[role="tree"][aria-label="Sessions"]', scaffold.workspaceCwd), + MODE, + ) const runningTrigger = page.getByRole('button', { name: '3 subagents running' }) await runningTrigger.waitFor({ timeout: 10_000 }) expect(await runningTrigger.locator('[data-state="ongoing"]').count()).toBe(1) diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 23c867e4c0..8556e393b6 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/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/runtime/README.md -README.md: 8ac29a4258bbd7456b20c61e547d48c570e84d27 -README.zh.md: 0e065e43ecc571e68d3976d2100eb43959cb2e3d +README.md: 00427f33b1dfc23b157c8fe4cfefb42cf313ee66 +README.zh.md: 0a27602a4408792e7f02ecd3995aeb5946e81aab diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 8ac29a4258..00427f33b1 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -22,6 +22,8 @@ Workspace and Session lists have independent monotone `pending` → `ready` base SlotsService gives the renderer separate bare observables for `useSessions` and `useWorkspaces`; web-react creates the hooks. Workspace business state does not enter `SessionListState` or an entry store. +`indexSubagentDescendants()` derives per-parent total and running descendant counts from the retained list mirror. It follows only uninterrupted `origin: 'subagent'` ancestry, so an ordinary fork starts a separate ownership subtree; cycles stop without throwing, and a missing parent remains a harmless key until its summary arrives. + `SessionsService.search(query, signal)` is a stateless one-shot action over the `session.search` RPC. It returns ranked session/snippet pairs without putting query, loading, or error state into the shared Session list, so each UI owner controls debounce, cancellation, stale-response suppression, and fallback presentation. `searchResultLimit` re-exposes `SESSION_SEARCH_RESULT_LIMIT` — the bound the response schema itself enforces — as injected presentation data, so client plugins do not duplicate it. It is a protocol constant rather than per-connection state, so the connection handle does not carry it. ## New Session and the blank mirror diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 0e065e43ec..0a27602a44 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -22,6 +22,8 @@ Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线 SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 observable;web-react 创建钩子。Workspace 业务状态不会进入 `SessionListState` 或配置项 store。 +`indexSubagentDescendants()` 从保留的列表镜像中派生每个 parent 的后代总数与运行中后代数。它只沿不间断的 `origin: 'subagent'` 祖先链追踪,因此普通 fork 会开启独立的归属子树;遇到环时,追踪会停止但不会抛出异常,缺失的 parent 则会保留为无害的键,直至其摘要到达。 + `SessionsService.search(query, signal)` 是基于 `session.search` RPC 的无状态单次操作。它返回经过排序的会话/snippet 对,但不会将查询条件、加载状态或错误状态写入共享 Session 列表,因此每个 UI 所有者都自行负责防抖、取消、抑制陈旧响应和回退呈现。`searchResultLimit` 将 `SESSION_SEARCH_RESULT_LIMIT`——即响应 schema 自身强制执行的上限——作为注入的呈现数据重新公开,使客户端插件无需复制该值。它是协议常量而非逐连接状态,因此连接 handle 不携带它。 ## New Session 与 blank 镜像 diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index e4f8e57b04..ceee6a1f10 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -15,6 +15,8 @@ export { SlotsService } from './slots.ts' export type { RootOwnerProps } from './slots.ts' export { SessionCreateError, SessionsService, scopeOf, workspaceTitleOf } from './sessions/service.ts' export { SessionHistoryService } from './session-history/service.ts' +export { indexSubagentDescendants } from './sessions/subagent-lineage.ts' +export type { SubagentDescendantSummary } from './sessions/subagent-lineage.ts' // The provide channel is shared with the client test runtime (one // materialization/projection implementation; no test-side mirror to drift). export { SessionProvideChannel } from './sessions/provide.ts' diff --git a/packages/client/runtime/src/client/sessions/subagent-lineage.ts b/packages/client/runtime/src/client/sessions/subagent-lineage.ts new file mode 100644 index 0000000000..fb56b4eb8c --- /dev/null +++ b/packages/client/runtime/src/client/sessions/subagent-lineage.ts @@ -0,0 +1,50 @@ +/** + * Pure subagent-lineage aggregation over the retained session-list mirror. + * Ordinary forks terminate propagation so each visible session owns only its + * uninterrupted subagent subtree. + * @module @deepseek-ai/dsh-client-runtime/client/sessions/subagent-lineage + */ +import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionSummary } from './service.ts' + +/** Descendant counts projected for one possible parent session. */ +export interface SubagentDescendantSummary { + /** All descendants connected through uninterrupted subagent-origin lineage. */ + readonly count: number + /** Descendants whose exact session summary is currently running. */ + readonly runningCount: number +} + +/** + * Index every subagent descendant under each ancestor it reaches through an + * uninterrupted subagent-origin chain. Cycles fail soft and orphan owners + * remain harmless map keys until their summaries arrive. + * @param summaries - retained session summaries keyed by id. + * @returns descendant totals and running totals keyed by possible parent id. + */ +export function indexSubagentDescendants( + summaries: Readonly>, +): ReadonlyMap { + const indexed = new Map() + for (const descendant of Object.values(summaries)) { + if (descendant.origin !== 'subagent') continue + const seen = new Set() + let current: SessionSummary | undefined = descendant + while (current?.origin === 'subagent' && current.parentId !== undefined + && !seen.has(current.id)) { + seen.add(current.id) + const aggregate = indexed.get(current.parentId) + if (aggregate === undefined) { + indexed.set(current.parentId, { + count: 1, + runningCount: descendant.running ? 1 : 0, + }) + } else { + aggregate.count += 1 + if (descendant.running) aggregate.runningCount += 1 + } + current = summaries[current.parentId] + } + } + return indexed +} diff --git a/packages/client/runtime/tests/subagent-lineage.spec.ts b/packages/client/runtime/tests/subagent-lineage.spec.ts new file mode 100644 index 0000000000..05881576bf --- /dev/null +++ b/packages/client/runtime/tests/subagent-lineage.spec.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client' +import { indexSubagentDescendants } from '@deepseek-ai/dsh-client-runtime/client' + +const sid = (id: string) => id as SessionId + +function summary( + id: string, + parentId?: SessionId, + origin?: 'subagent', + running = false, +): SessionSummary { + return { + id: sid(id), displayTitle: id, running, blank: false, updatedAt: 0, + ...(parentId === undefined ? {} : { parentId }), + ...(origin === undefined ? {} : { origin }), + } +} + +function index(...summaries: SessionSummary[]) { + return indexSubagentDescendants(Object.fromEntries( + summaries.map(item => [item.id, item]), + )) +} + +describe('indexSubagentDescendants', () => { + it('counts every nested descendant and its exact running state', () => { + const owner = summary('owner') + const child = summary('child', owner.id, 'subagent') + const grandchild = summary('grandchild', child.id, 'subagent', true) + + const result = index(owner, child, grandchild) + expect(result.get(owner.id)).toEqual({ count: 2, runningCount: 1 }) + expect(result.get(child.id)).toEqual({ count: 1, runningCount: 1 }) + }) + + it('stops at ordinary forks and fails soft on cycles and missing parents', () => { + const owner = summary('owner') + const child = summary('child', owner.id, 'subagent', true) + const fork = summary('fork', child.id) + const forkChild = summary('fork-child', fork.id, 'subagent', true) + const orphan = summary('orphan', sid('missing'), 'subagent', true) + const cycleA = summary('cycle-a', sid('cycle-b'), 'subagent') + const cycleB = summary('cycle-b', sid('cycle-a'), 'subagent') + + const result = index(owner, child, fork, forkChild, orphan, cycleA, cycleB) + expect(result.get(owner.id)).toEqual({ count: 1, runningCount: 1 }) + expect(result.get(fork.id)).toEqual({ count: 1, runningCount: 1 }) + expect(result.get(sid('missing'))).toEqual({ count: 1, runningCount: 1 }) + expect(result.get(cycleA.id)).toEqual({ count: 2, runningCount: 0 }) + expect(result.get(cycleB.id)).toEqual({ count: 2, runningCount: 0 }) + }) +}) diff --git a/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx b/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx index 76a216ebd8..50850f0b98 100644 --- a/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx +++ b/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx @@ -1,9 +1,9 @@ import { - useEffect, useRef, useState, type KeyboardEvent, type MouseEvent, + useEffect, useMemo, useRef, useState, type KeyboardEvent, type MouseEvent, } from 'react' -import type { - SessionId, SessionListState, SessionProjectionMap, SessionSummary, SubagentAddress, - SubagentCatalogSnapshot, +import { + indexSubagentDescendants, type SessionId, type SessionListState, type SessionProjectionMap, + type SessionSummary, type SubagentAddress, type SubagentCatalogSnapshot, } from '@deepseek-ai/dsh-client-runtime/client' import { IconChevronDownOutline14, IconChevronRightOutline14, IconRefreshOutline14, StateDot, @@ -171,30 +171,7 @@ function formatExactDuration(ms: number, t: TranslateNS): string { }) } -/** Aggregate the complete subagent-only descendant subtree from flat summaries. */ -function summarizeDescendants( - sessionId: SessionId, - summaries: Readonly>, -): { count: number; running: boolean } { - let count = 0 - let running = false - for (const summary of Object.values(summaries)) { - if (summary.origin !== 'subagent') continue - const seen = new Set() - let current: SessionSummary | undefined = summary - while (current?.origin === 'subagent' && current.parentId !== undefined - && !seen.has(current.id)) { - seen.add(current.id) - if (current.parentId === sessionId) { - count += 1 - running ||= summary.running - break - } - current = summaries[current.parentId] - } - } - return { count, running } -} +const NO_DESCENDANTS = { count: 0, runningCount: 0 } as const /** Render the known direct-child shape while its authoritative catalog hydrates. */ function CatalogLoadingRows({ @@ -448,7 +425,10 @@ export function SubagentCatalogAction({ const setCatalogOpenRef = useRef(setCatalogOpen) setCatalogOpenRef.current = setCatalogOpen const healthy = catalog?.entries.filter(entry => entry.kind === 'child') ?? [] - const descendants = summarizeDescendants(sessionId, summaries) + const descendants = useMemo( + () => indexSubagentDescendants(summaries).get(sessionId) ?? NO_DESCENDANTS, + [sessionId, summaries], + ) // The catalog can arrive before the session-list baseline; never undercount // the already-visible direct rows during that short bootstrap window. const descendantCount = Math.max(healthy.length, descendants.count) @@ -527,10 +507,10 @@ export function SubagentCatalogAction({ }, [open]) useEffect(() => { - if (!open || !descendants.running) return + if (!open || descendants.runningCount === 0) return const timer = setInterval(() => { setNow(Date.now()) }, 1_000) return () => { clearInterval(timer) } - }, [open, descendants.running]) + }, [open, descendants.runningCount]) useEffect(() => () => { for (const parentSessionId of observedCatalogs.current) { @@ -584,7 +564,7 @@ export function SubagentCatalogAction({ className={css.trigger} aria-haspopup="tree" aria-expanded={open} - aria-label={t(descendants.running ? runningCountKey : totalCountKey, { count: descendantCount })} + aria-label={t(descendants.runningCount > 0 ? runningCountKey : totalCountKey, { count: descendantCount })} onClick={() => { changeOpen(!open) }} onKeyDown={(event) => { if (event.key !== 'ArrowDown') return @@ -594,7 +574,7 @@ export function SubagentCatalogAction({ }} > - {descendants.running && } + {descendants.runningCount > 0 && } {t(totalCountKey, { count: descendantCount })} diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index fee956b683..c5cfc89ea5 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/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-workspace/README.md -README.md: bd7313b560e76378e4fff274c99bb976819aebae -README.zh.md: 734a897b9cb9c3469d8f402b13bff4b62753f9b2 +README.md: b2baea049c30f46c3194009e71c70b38973dc526 +README.zh.md: cc50ba3691db10a533337b0e99689fba72a679ca diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index bd7313b560..b2baea049c 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -16,7 +16,7 @@ Session rows render the runtime's live `pendingInteraction` classification: appr Both target slots are declared by other plugins, so `apply` uses `slots.inject()` to register for each declaration lifetime and re-register after a declaring slot is restored. -The shared sidebar projection hides rows whose durable Session summary has `origin: 'subagent'`; users enter those conversations through the selected parent's subagent header catalog. Ordinary forks remain visible because lineage alone does not set that origin. The runtime keeps hidden rows available for conversation, title, and addressed transport state. +The shared sidebar projection hides rows whose durable Session summary has `origin: 'subagent'`; users enter those conversations through the selected parent's subagent header catalog. Each visible ordinary row inherits the blue activity indicator while any descendant reached through uninterrupted subagent-origin lineage is running, and its hover and assistive text report the exact running-descendant count without describing an idle parent as running. Ordinary forks remain visible and terminate this aggregation because lineage alone does not set their origin. Pending user interaction remains the primary row marker while descendant activity stays available as a separate hover and assistive status. The runtime keeps hidden rows available for conversation, title, and addressed transport state. ## Model Experience diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index 734a897b9c..cc50ba3691 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -16,7 +16,7 @@ Session 行渲染运行时的实时 `pendingInteraction` 分类:审批显示** 两个目标 slot 都由其他插件声明,因此 `apply` 使用 `slots.inject()` 在各自的声明生命周期内完成注册,并在目标 slot 的声明恢复后重新注册。 -共享侧边栏投影会隐藏持久化 Session 摘要中带有 `origin: 'subagent'` 的行;用户从所选 parent 的 subagent 页头目录进入这些对话。普通 fork 仍然可见,因为仅有谱系不会设置该 origin。运行时仍保留隐藏行,供对话、标题与已寻址传输状态使用。 +共享侧边栏投影会隐藏持久化 Session 摘要中带有 `origin: 'subagent'` 的行;用户从所选 parent 的 subagent 页头目录进入这些对话。每个可见的普通行都会在经不间断的 subagent 谱系可达的任一后代运行时继承蓝色活动指示器;其悬停与无障碍文本会报告确切的运行中后代数量,同时不会把空闲 parent 描述为正在运行。普通 fork 仍然可见,并会终止此聚合,因为仅有谱系不会设置该 origin。待处理的用户交互仍是主要行标记,而后代活动会作为独立的悬停与无障碍状态保留。运行时仍保留隐藏行,供对话、标题与已寻址传输状态使用。 ## 模型体验 diff --git a/packages/client/ui-workspace/src/client/locales.ts b/packages/client/ui-workspace/src/client/locales.ts index d9c70de729..30fe6bfcc0 100644 --- a/packages/client/ui-workspace/src/client/locales.ts +++ b/packages/client/ui-workspace/src/client/locales.ts @@ -45,6 +45,8 @@ export const zh = { 'actions.session.aria': '会话“{name}”的操作', 'actions.newSession.aria': '在“{name}”中新建会话', 'status.running': '进行中', + 'status.subagentsRunning.one': '{n} 个子代理运行中', + 'status.subagentsRunning.other': '{n} 个子代理运行中', 'status.idle': '空闲', 'status.waitingApproval': '等待审批', 'status.planReview': '计划待审', @@ -106,6 +108,8 @@ export const en = { 'actions.session.aria': 'Session actions for {name}', 'actions.newSession.aria': 'New session in {name}', 'status.running': 'Running', + 'status.subagentsRunning.one': '{n} subagent running', + 'status.subagentsRunning.other': '{n} subagents running', 'status.idle': 'Idle', 'status.waitingApproval': 'Waiting for approval', 'status.planReview': 'Plan awaiting review', diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index fb64a0be42..3d3b40403d 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -171,37 +171,67 @@ function assertNever(value: never): never { throw new Error(`unknown pending interaction: ${String(value)}`) } -/** Session status presentation; pending user interaction outranks the running state. */ -function sessionStatus( - node: Pick, +interface SessionStatus { + state: StateDotState + label: string +} + +/** Session status presentation; pending user interaction remains primary. */ +function sessionStatuses( + node: Pick, t: RowTranslate, -): { state: StateDotState; label: string } { +): readonly [SessionStatus, ...SessionStatus[]] { + const subagents: SessionStatus | undefined = node.runningSubagentCount === 0 + ? undefined + : { + state: 'ongoing', + label: t( + node.runningSubagentCount === 1 + ? 'status.subagentsRunning.one' + : 'status.subagentsRunning.other', + { n: node.runningSubagentCount }, + ), + } + let pending: SessionStatus | undefined switch (node.pendingInteraction) { - case 'approval': return { state: 'warning', label: t('status.waitingApproval') } - case 'plan-review': return { state: 'warning', label: t('status.planReview') } - case 'question': return { state: 'warning', label: t('status.waitingAnswer') } + case 'approval': + pending = { state: 'warning', label: t('status.waitingApproval') } + break + case 'plan-review': + pending = { state: 'warning', label: t('status.planReview') } + break + case 'question': + pending = { state: 'warning', label: t('status.waitingAnswer') } + break case undefined: break /* v8 ignore next -- closed PendingInteractionStatus union */ default: return assertNever(node.pendingInteraction) } - if (node.running) return { state: 'ongoing', label: t('status.running') } - if (node.completed) return { state: 'done', label: t('status.completed') } - return { state: 'done', label: t('status.idle') } + if (pending !== undefined) return subagents === undefined ? [pending] : [pending, subagents] + if (node.running) { + const primary: SessionStatus = { state: 'ongoing', label: t('status.running') } + return subagents === undefined ? [primary] : [primary, subagents] + } + if (subagents !== undefined) return [subagents] + if (node.completed) return [{ state: 'done', label: t('status.completed') }] + return [{ state: 'done', label: t('status.idle') }] } -/** Hover-card body: full title, relative time, and interaction/running/completed/idle status. */ +/** Hover-card body: full title, relative time, and every relevant live status. */ function SessionHoverContent({ node, now, t }: { node: SessionNode; now: number; t: RowTranslate }) { - const status = sessionStatus(node, t) + const statuses = sessionStatuses(node, t) return (
{displayTitle(node, t)}
{/* Same placeholder rule as the row's trailing cell: no timestamp before the first prompt. */} {!node.blank &&
{hoverTimeLabel(node.updatedAt, now, t)}
} -
- - {status.label} -
+ {statuses.map(status => ( +
+ + {status.label} +
+ ))}
) } @@ -241,7 +271,8 @@ export function SearchResultItem({ result, currentId, onOpen, t }: { t: RowTranslate }) { const selected = result.id === currentId - const status = sessionStatus(result, t) + const statuses = sessionStatuses(result, t) + const primaryStatus = statuses[0] return (